From d183e321e8be06d5689777b1ce8629c445a6f873 Mon Sep 17 00:00:00 2001 From: Tubagus Fajri Date: Sat, 22 Aug 2026 10:14:12 +0700 Subject: [PATCH] feat: add cooperation tariff APIs --- README.md | 6 + cmd/server/main.go | 2 + internal/http/handler.go | 9 +- internal/http/tarif_kerjasama.go | 179 ++++++++++ internal/http/tarif_kerjasama_test.go | 146 ++++++++ internal/repository/tarif_kerjasama.go | 376 ++++++++++++++++++++ internal/repository/tarif_kerjasama_test.go | 81 +++++ 7 files changed, 798 insertions(+), 1 deletion(-) create mode 100644 internal/http/tarif_kerjasama.go create mode 100644 internal/http/tarif_kerjasama_test.go create mode 100644 internal/repository/tarif_kerjasama.go create mode 100644 internal/repository/tarif_kerjasama_test.go diff --git a/README.md b/README.md index f653adc..f744f14 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,12 @@ GET /api/cpone/paket GET /api/cpone/paket/{id} Authorization: Bearer +GET /api/cpone/tarif-kerjasama?search=&perusahaan_id=&tarif_id=&page=1&per_page=15 +Authorization: Bearer + +GET /api/cpone/tarif-kerjasama/{id}?group_tarif_id=&active=N&effective_date=2026-08-22 +Authorization: Bearer + GET /api/cpone/layanan-mapping-harga?periodeTarifID=10&layananID=6413,6414,1309 Authorization: Bearer diff --git a/cmd/server/main.go b/cmd/server/main.go index ca8832e..9fbf95d 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -46,6 +46,8 @@ func main() { mux := http.NewServeMux() mux.Handle("GET /api/cpone/paket", apphttp.BearerAuth(cfg.BearerToken, http.HandlerFunc(handler.ListPaket))) mux.Handle("GET /api/cpone/paket/{id}", apphttp.BearerAuth(cfg.BearerToken, http.HandlerFunc(handler.GetPaket))) + mux.Handle("GET /api/cpone/tarif-kerjasama", apphttp.BearerAuth(cfg.BearerToken, http.HandlerFunc(handler.ListTarifKerjasama))) + mux.Handle("GET /api/cpone/tarif-kerjasama/{id}", apphttp.BearerAuth(cfg.BearerToken, http.HandlerFunc(handler.GetTarifKerjasama))) mux.Handle("GET /api/cpone/layanan-mapping-harga", apphttp.BearerAuth(cfg.BearerToken, http.HandlerFunc(handler.MappingHarga))) mux.Handle("POST /api/cpone/patients/medrec", apphttp.BearerAuth(cfg.BearerToken, http.HandlerFunc(handler.StoreMedrec))) mux.Handle("POST /api/cpone/patients/lab-registration", apphttp.BearerAuth(cfg.BearerToken, http.HandlerFunc(handler.RegisterLab))) diff --git a/internal/http/handler.go b/internal/http/handler.go index bbea149..deac5e5 100644 --- a/internal/http/handler.go +++ b/internal/http/handler.go @@ -34,12 +34,18 @@ type PaketRepository interface { GetPaket(ctx context.Context, id string) (*repository.PaketDetail, error) } +type TarifKerjasamaRepository interface { + ListTarifKerjasama(ctx context.Context, params repository.TarifKerjasamaListParams) (repository.TarifKerjasamaPage, error) + GetTarifKerjasama(ctx context.Context, id int64, params repository.TarifKerjasamaDetailParams) (*repository.TarifKerjasamaDetail, error) +} + type Handler struct { layananRepo LayananRepository labRepo LabRegistrationRepository patientRepo PatientRepository labResultRepo LabResultRepository paketRepo PaketRepository + tarifRepo TarifKerjasamaRepository } type CPONERepository interface { @@ -48,12 +54,13 @@ type CPONERepository interface { PatientRepository LabResultRepository PaketRepository + TarifKerjasamaRepository } func NewHandler(repo CPONERepository) Handler { return Handler{ layananRepo: repo, labRepo: repo, patientRepo: repo, - labResultRepo: repo, paketRepo: repo, + labResultRepo: repo, paketRepo: repo, tarifRepo: repo, } } diff --git a/internal/http/tarif_kerjasama.go b/internal/http/tarif_kerjasama.go new file mode 100644 index 0000000..1ef4823 --- /dev/null +++ b/internal/http/tarif_kerjasama.go @@ -0,0 +1,179 @@ +package http + +import ( + "net/http" + "strconv" + "strings" + "time" + "unicode/utf8" + + "primaya-api/cpone-middleware/internal/repository" +) + +type TarifKerjasamaPaginationResponse struct { + CurrentPage int `json:"current_page"` + Data []repository.TarifKerjasamaSummary `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"` +} + +type TarifKerjasamaResponse struct { + Success bool `json:"success"` + Status string `json:"status"` + Message string `json:"message"` + Data any `json:"data"` +} + +func (h Handler) ListTarifKerjasama(w http.ResponseWriter, r *http.Request) { + params, message := parseTarifKerjasamaListParams(r) + if message != "" { + writeTarifKerjasamaValidationError(w, message) + return + } + + page, err := h.tarifRepo.ListTarifKerjasama(r.Context(), params) + if err != nil { + writeTarifKerjasamaInternalError(w) + return + } + + lastPage := (page.Total + params.PerPage - 1) / params.PerPage + if lastPage < 1 { + lastPage = 1 + } + var from *int + if len(page.Items) > 0 { + value := (params.Page-1)*params.PerPage + 1 + from = &value + } + var nextPageURL *string + if params.Page < lastPage { + value := currentRequestURL(r, params.Page+1) + nextPageURL = &value + } + + WriteJSON(w, http.StatusOK, TarifKerjasamaPaginationResponse{ + CurrentPage: params.Page, Data: page.Items, FirstPageURL: "", From: from, + LastPage: lastPage, LastPageURL: "", Links: map[string]any{}, + NextPageURL: nextPageURL, Total: page.Total, PerPage: params.PerPage, + }) +} + +func (h Handler) GetTarifKerjasama(w http.ResponseWriter, r *http.Request) { + id, params, message := parseTarifKerjasamaDetailParams(r) + if message != "" { + writeTarifKerjasamaValidationError(w, message) + return + } + + detail, err := h.tarifRepo.GetTarifKerjasama(r.Context(), id, params) + if err != nil { + writeTarifKerjasamaInternalError(w) + return + } + if detail == nil { + WriteJSON(w, http.StatusNotFound, TarifKerjasamaResponse{ + Success: false, Status: "tarif_kerjasama_not_found", + Message: "Master tarif kerja sama tidak ditemukan", Data: nil, + }) + return + } + + WriteJSON(w, http.StatusOK, TarifKerjasamaResponse{ + Success: true, Status: "tarif_kerjasama_found", + Message: "Data tarif kerja sama berhasil diambil", Data: detail, + }) +} + +func parseTarifKerjasamaListParams(r *http.Request) (repository.TarifKerjasamaListParams, string) { + query := r.URL.Query() + params := repository.TarifKerjasamaListParams{ + Search: strings.TrimSpace(query.Get("search")), + PerusahaanID: strings.TrimSpace(query.Get("perusahaan_id")), + Page: 1, PerPage: 15, + } + if utf8.RuneCountInString(params.Search) > 255 { + return params, "The search may not be greater than 255 characters." + } + if utf8.RuneCountInString(params.PerusahaanID) > 50 { + return params, "The perusahaan id may not be greater than 50 characters." + } + if raw, exists := firstQueryValue(r, "tarif_id"); exists && raw != "" { + value, err := positiveInt64(raw) + if err != nil { + return params, "The tarif id must be a positive integer." + } + params.TarifID = &value + } + if raw, exists := firstQueryValue(r, "page"); exists && raw != "" { + value, err := strconv.Atoi(raw) + if err != nil || value < 1 { + return params, "The page must be at least 1." + } + params.Page = value + } + if raw, exists := firstQueryValue(r, "per_page"); exists && raw != "" { + value, err := strconv.Atoi(raw) + if err != nil || value < 1 || value > 100 { + return params, "The per page must be between 1 and 100." + } + params.PerPage = value + } + + return params, "" +} + +func parseTarifKerjasamaDetailParams(r *http.Request) (int64, repository.TarifKerjasamaDetailParams, string) { + params := repository.TarifKerjasamaDetailParams{Active: "N"} + id, err := positiveInt64(strings.TrimSpace(r.PathValue("id"))) + if err != nil { + return 0, params, "The id must be a positive integer." + } + if raw, exists := firstQueryValue(r, "group_tarif_id"); exists && raw != "" { + value, err := positiveInt64(raw) + if err != nil { + return 0, params, "The group tarif id must be a positive integer." + } + params.GroupTarifID = &value + } + if raw, exists := firstQueryValue(r, "active"); exists && raw != "" { + if raw != "N" && raw != "Y" { + return 0, params, "The selected active is invalid." + } + params.Active = raw + } + params.EffectiveDate = strings.TrimSpace(r.URL.Query().Get("effective_date")) + if params.EffectiveDate != "" { + if _, err := time.Parse("2006-01-02", params.EffectiveDate); err != nil { + return 0, params, "The effective date does not match the format Y-m-d." + } + } + + return id, params, "" +} + +func positiveInt64(raw string) (int64, error) { + value, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64) + if err != nil || value < 1 { + return 0, strconv.ErrSyntax + } + return value, nil +} + +func writeTarifKerjasamaValidationError(w http.ResponseWriter, message string) { + WriteJSON(w, http.StatusUnprocessableEntity, TarifKerjasamaResponse{ + Success: false, Status: "validation_error", Message: message, Data: nil, + }) +} + +func writeTarifKerjasamaInternalError(w http.ResponseWriter) { + WriteJSON(w, http.StatusInternalServerError, Response{ + Success: false, Message: "internal server error", Data: nil, + }) +} diff --git a/internal/http/tarif_kerjasama_test.go b/internal/http/tarif_kerjasama_test.go new file mode 100644 index 0000000..9a274d8 --- /dev/null +++ b/internal/http/tarif_kerjasama_test.go @@ -0,0 +1,146 @@ +package http + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "primaya-api/cpone-middleware/internal/repository" +) + +type fakeTarifKerjasamaRepository struct { + page repository.TarifKerjasamaPage + detail *repository.TarifKerjasamaDetail + err error + listParams repository.TarifKerjasamaListParams + detailParams repository.TarifKerjasamaDetailParams + detailID int64 +} + +func (f *fakeTarifKerjasamaRepository) ListTarifKerjasama(_ context.Context, params repository.TarifKerjasamaListParams) (repository.TarifKerjasamaPage, error) { + f.listParams = params + return f.page, f.err +} + +func (f *fakeTarifKerjasamaRepository) GetTarifKerjasama(_ context.Context, id int64, params repository.TarifKerjasamaDetailParams) (*repository.TarifKerjasamaDetail, error) { + f.detailID = id + f.detailParams = params + return f.detail, f.err +} + +func TestListTarifKerjasamaResponseAndFilters(t *testing.T) { + kode, pic := "INS-001", "Bapak A" + repo := &fakeTarifKerjasamaRepository{page: repository.TarifKerjasamaPage{ + Total: 16, + Items: []repository.TarifKerjasamaSummary{{ + PerusahaanTarifID: 1098, PerusahaanID: "COMP-001", KodePerusahaan: &kode, + NamaPerusahaan: "24 Medicare", PIC: &pic, JumlahGroupTarif: 2, + }}, + }} + handler := Handler{tarifRepo: repo} + request := httptest.NewRequest(http.MethodGet, "http://localhost/api/cpone/tarif-kerjasama?search=medicare&perusahaan_id=COMP-001&tarif_id=1098&page=1&per_page=15", nil) + recorder := httptest.NewRecorder() + + handler.ListTarifKerjasama(recorder, request) + + if recorder.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String()) + } + if repo.listParams.Search != "medicare" || repo.listParams.PerusahaanID != "COMP-001" || repo.listParams.TarifID == nil || *repo.listParams.TarifID != 1098 { + t.Fatalf("params mismatch: %+v", repo.listParams) + } + for _, expected := range []string{ + `"PerusahaanTarifID":1098`, `"NamaPerusahaan":"24 Medicare"`, + `"JumlahGroupTarif":2`, `"last_page":2`, + } { + if !strings.Contains(recorder.Body.String(), expected) { + t.Fatalf("response does not contain %s: %s", expected, recorder.Body.String()) + } + } +} + +func TestGetTarifKerjasamaNestedResponse(t *testing.T) { + layananID, namaLayanan := "LAB-001", "Darah Lengkap" + kelasID, namaKelas := "KLS-1", "Kelas I" + detail := &repository.TarifKerjasamaDetail{ + TarifKerjasamaSummary: repository.TarifKerjasamaSummary{ + PerusahaanTarifID: 1098, PerusahaanID: "COMP-001", + NamaPerusahaan: "24 Medicare", JumlahGroupTarif: 1, + }, + GroupTarif: []repository.TarifKerjasamaGroup{{ + GroupTarifID: 501, PerusahaanTarifID: 1098, + NamaGroupTarif: "Tarif Corporate 2026", NA: "N", Aktif: true, + JumlahLayanan: 1, + Layanan: []repository.TarifKerjasamaLayanan{{ + LayananID: &layananID, NamaLayanan: &namaLayanan, JumlahDetail: 1, + Detail: []repository.TarifKerjasamaLayananDetail{{ + TarifLayananID: 1, KelasID: &kelasID, NamaKelas: &namaKelas, Harga: 125000, + }}, + }}, + }}, + } + repo := &fakeTarifKerjasamaRepository{detail: detail} + handler := Handler{tarifRepo: repo} + request := httptest.NewRequest(http.MethodGet, "/api/cpone/tarif-kerjasama/1098?group_tarif_id=501&effective_date=2026-08-22", nil) + request.SetPathValue("id", "1098") + recorder := httptest.NewRecorder() + + handler.GetTarifKerjasama(recorder, request) + + if recorder.Code != http.StatusOK || repo.detailID != 1098 { + t.Fatalf("status=%d id=%d body=%s", recorder.Code, repo.detailID, recorder.Body.String()) + } + if repo.detailParams.GroupTarifID == nil || *repo.detailParams.GroupTarifID != 501 || repo.detailParams.Active != "N" || repo.detailParams.EffectiveDate != "2026-08-22" { + t.Fatalf("params mismatch: %+v", repo.detailParams) + } + for _, expected := range []string{ + `"status":"tarif_kerjasama_found"`, `"GroupTarifID":501`, + `"LayananID":"LAB-001"`, `"Harga":125000`, + } { + if !strings.Contains(recorder.Body.String(), expected) { + t.Fatalf("response does not contain %s: %s", expected, recorder.Body.String()) + } + } +} + +func TestTarifKerjasamaValidationAndNotFound(t *testing.T) { + tests := []struct { + path string + id string + }{ + {"/api/cpone/tarif-kerjasama/abc", "abc"}, + {"/api/cpone/tarif-kerjasama/1?group_tarif_id=0", "1"}, + {"/api/cpone/tarif-kerjasama/1?active=X", "1"}, + {"/api/cpone/tarif-kerjasama/1?effective_date=22-08-2026", "1"}, + } + for _, test := range tests { + request := httptest.NewRequest(http.MethodGet, test.path, nil) + request.SetPathValue("id", test.id) + recorder := httptest.NewRecorder() + Handler{tarifRepo: &fakeTarifKerjasamaRepository{}}.GetTarifKerjasama(recorder, request) + if recorder.Code != http.StatusUnprocessableEntity || !strings.Contains(recorder.Body.String(), `"status":"validation_error"`) { + t.Fatalf("path=%s status=%d body=%s", test.path, recorder.Code, recorder.Body.String()) + } + } + + request := httptest.NewRequest(http.MethodGet, "/api/cpone/tarif-kerjasama/9999", nil) + request.SetPathValue("id", "9999") + recorder := httptest.NewRecorder() + Handler{tarifRepo: &fakeTarifKerjasamaRepository{}}.GetTarifKerjasama(recorder, request) + if recorder.Code != http.StatusNotFound || !strings.Contains(recorder.Body.String(), `"status":"tarif_kerjasama_not_found"`) { + t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String()) + } +} + +func TestTarifKerjasamaRepositoryErrorsAreHidden(t *testing.T) { + handler := Handler{tarifRepo: &fakeTarifKerjasamaRepository{err: errors.New("database unavailable")}} + request := httptest.NewRequest(http.MethodGet, "/api/cpone/tarif-kerjasama", nil) + recorder := httptest.NewRecorder() + handler.ListTarifKerjasama(recorder, request) + if recorder.Code != http.StatusInternalServerError || recorder.Body.String() != `{"success":false,"message":"internal server error","data":null}`+"\n" { + t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String()) + } +} diff --git a/internal/repository/tarif_kerjasama.go b/internal/repository/tarif_kerjasama.go new file mode 100644 index 0000000..e936879 --- /dev/null +++ b/internal/repository/tarif_kerjasama.go @@ -0,0 +1,376 @@ +package repository + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strconv" + "strings" + + "github.com/go-sql-driver/mysql" +) + +type TarifKerjasamaListParams struct { + Search string + PerusahaanID string + TarifID *int64 + Page int + PerPage int +} + +type TarifKerjasamaDetailParams struct { + GroupTarifID *int64 + Active string + EffectiveDate string +} + +type TarifKerjasamaPage struct { + Items []TarifKerjasamaSummary + Total int +} + +type TarifKerjasamaSummary struct { + PerusahaanTarifID int64 `json:"PerusahaanTarifID"` + PerusahaanID string `json:"PerusahaanID"` + KodePerusahaan *string `json:"KodePerusahaan"` + NamaPerusahaan string `json:"NamaPerusahaan"` + PIC *string `json:"PIC"` + Alamat *string `json:"Alamat"` + Telepon *string `json:"Telepon"` + Email *string `json:"Email"` + JumlahGroupTarif int `json:"JumlahGroupTarif"` +} + +type TarifKerjasamaDetail struct { + TarifKerjasamaSummary + GroupTarif []TarifKerjasamaGroup `json:"GroupTarif"` +} + +type TarifKerjasamaGroup struct { + GroupTarifID int64 `json:"GroupTarifID"` + PerusahaanTarifID int64 `json:"PerusahaanTarifID"` + NamaGroupTarif string `json:"NamaGroupTarif"` + TanggalBerlaku string `json:"TanggalBerlaku"` + TanggalBerakhir string `json:"TanggalBerakhir"` + PeriodeTarifID *int64 `json:"PeriodeTarifID"` + KelasDefault *string `json:"KelasDefault"` + SemuaLayanan *string `json:"SemuaLayanan"` + DiskonSemuaLayananRJ *float64 `json:"DiskonSemuaLayananRJ"` + DiskonSemuaLayananRI *float64 `json:"DiskonSemuaLayananRI"` + Keterangan *string `json:"Keterangan"` + NA string `json:"NA"` + Aktif bool `json:"Aktif"` + JumlahLayanan int `json:"JumlahLayanan"` + Layanan []TarifKerjasamaLayanan `json:"Layanan"` +} + +type TarifKerjasamaLayanan struct { + LayananID *string `json:"LayananID"` + NamaLayanan *string `json:"NamaLayanan"` + JenisLayananID *string `json:"JenisLayananID"` + NamaJenisLayanan *string `json:"NamaJenisLayanan"` + JumlahDetail int `json:"JumlahDetail"` + Detail []TarifKerjasamaLayananDetail `json:"Detail"` +} + +type TarifKerjasamaLayananDetail struct { + TarifLayananID int64 `json:"TarifLayananID"` + KelasID *string `json:"KelasID"` + NamaKelas *string `json:"NamaKelas"` + Harga float64 `json:"Harga"` + TipeDiskonRJ *string `json:"TipeDiskonRJ"` + DiskonRJ float64 `json:"DiskonRJ"` + TipeDiskonRI *string `json:"TipeDiskonRI"` + DiskonRI float64 `json:"DiskonRI"` + TipeShare *string `json:"TipeShare"` + ShareDokter float64 `json:"ShareDokter"` + ShareRS float64 `json:"ShareRS"` + ShareExternal float64 `json:"ShareExternal"` + ShareLain float64 `json:"ShareLain"` + AsalTarif *string `json:"AsalTarif"` +} + +func (r MySQLLayananRepository) ListTarifKerjasama(ctx context.Context, params TarifKerjasamaListParams) (TarifKerjasamaPage, error) { + where, args := tarifKerjasamaListWhere(params) + var total int + countQuery := `SELECT COUNT(*) FROM perusahaan_tarif pt + INNER JOIN perusahaan p ON p.PerusahaanID = pt.PerusahaanID ` + where + if err := r.db.QueryRowContext(ctx, countQuery, args...).Scan(&total); err != nil { + return TarifKerjasamaPage{}, err + } + + query := `SELECT pt.Perusahaan_TarifID, pt.PerusahaanID, p.KodePerusahaan, + p.Nama, pt.PenanggungJawab, p.Alamat, pt.TeleponPenanggungJawab, + pt.EmailPenanggungJawab, + (SELECT COUNT(*) FROM perusahaan_grouptarif pg + WHERE pg.Perusahaan_TarifID = pt.Perusahaan_TarifID AND pg.NA = 'N') + FROM perusahaan_tarif pt + INNER JOIN perusahaan p ON p.PerusahaanID = pt.PerusahaanID ` + where + ` + ORDER BY p.Nama, pt.Perusahaan_TarifID + LIMIT ? OFFSET ?` + queryArgs := append(append([]any{}, args...), params.PerPage, (params.Page-1)*params.PerPage) + rows, err := r.db.QueryContext(ctx, query, queryArgs...) + if err != nil { + return TarifKerjasamaPage{}, err + } + defer rows.Close() + + items := make([]TarifKerjasamaSummary, 0) + for rows.Next() { + item, err := scanTarifKerjasamaSummary(rows) + if err != nil { + return TarifKerjasamaPage{}, err + } + items = append(items, item) + } + if err := rows.Err(); err != nil { + return TarifKerjasamaPage{}, err + } + return TarifKerjasamaPage{Items: items, Total: total}, nil +} + +func tarifKerjasamaListWhere(params TarifKerjasamaListParams) (string, []any) { + conditions := []string{"p.NA = 'N'"} + args := make([]any, 0, 6) + if params.Search != "" { + like := "%" + params.Search + "%" + conditions = append(conditions, `(p.Nama LIKE ? OR p.KodePerusahaan LIKE ? + OR pt.PerusahaanID LIKE ? OR pt.PenanggungJawab LIKE ?)`) + args = append(args, like, like, like, like) + } + if params.PerusahaanID != "" { + conditions = append(conditions, "pt.PerusahaanID = ?") + args = append(args, params.PerusahaanID) + } + if params.TarifID != nil { + conditions = append(conditions, "pt.Perusahaan_TarifID = ?") + args = append(args, *params.TarifID) + } + return "WHERE " + strings.Join(conditions, " AND "), args +} + +func (r MySQLLayananRepository) GetTarifKerjasama(ctx context.Context, id int64, params TarifKerjasamaDetailParams) (*TarifKerjasamaDetail, error) { + row := r.db.QueryRowContext(ctx, `SELECT pt.Perusahaan_TarifID, pt.PerusahaanID, + p.KodePerusahaan, p.Nama, pt.PenanggungJawab, p.Alamat, + pt.TeleponPenanggungJawab, pt.EmailPenanggungJawab, 0 + FROM perusahaan_tarif pt + INNER JOIN perusahaan p ON p.PerusahaanID = pt.PerusahaanID + WHERE pt.Perusahaan_TarifID = ? AND p.NA = 'N' LIMIT 1`, id) + master, err := scanTarifKerjasamaSummary(row) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + + groups, err := r.tarifKerjasamaGroups(ctx, id, params) + if err != nil { + return nil, err + } + master.JumlahGroupTarif = len(groups) + return &TarifKerjasamaDetail{TarifKerjasamaSummary: master, GroupTarif: groups}, nil +} + +func scanTarifKerjasamaSummary(row rowScanner) (TarifKerjasamaSummary, error) { + var item TarifKerjasamaSummary + var kode, pic, alamat, telepon, email sql.NullString + err := row.Scan(&item.PerusahaanTarifID, &item.PerusahaanID, &kode, + &item.NamaPerusahaan, &pic, &alamat, &telepon, &email, &item.JumlahGroupTarif) + item.KodePerusahaan = nullStringPtr(kode) + item.PIC = nullStringPtr(pic) + item.Alamat = nullStringPtr(alamat) + item.Telepon = nullStringPtr(telepon) + item.Email = nullStringPtr(email) + return item, err +} + +func (r MySQLLayananRepository) tarifKerjasamaGroups(ctx context.Context, id int64, params TarifKerjasamaDetailParams) ([]TarifKerjasamaGroup, error) { + if params.Active == "" { + params.Active = "N" + } + conditions := []string{"pg.Perusahaan_TarifID = ?", "pg.NA = ?"} + args := []any{id, params.Active} + if params.GroupTarifID != nil { + conditions = append(conditions, "pg.GroupTarifID = ?") + args = append(args, *params.GroupTarifID) + } + if params.EffectiveDate != "" { + conditions = append(conditions, "DATE(pg.TanggalBerlaku) <= ?", "DATE(pg.TanggalBerakhir) >= ?") + args = append(args, params.EffectiveDate, params.EffectiveDate) + } + + query := `SELECT pg.GroupTarifID, pg.Perusahaan_TarifID, pg.Keterangan, + CAST(pg.TanggalBerlaku AS CHAR), CAST(pg.TanggalBerakhir AS CHAR), + pg.PeriodeTarifID, pg.KelasDefault, pg.SemuaLayanan, + CAST(pg.HargaLayanan AS CHAR), CAST(pg.HargaLayananRI AS CHAR), pg.NA + FROM perusahaan_grouptarif pg WHERE ` + strings.Join(conditions, " AND ") + ` + ORDER BY pg.TanggalBerlaku DESC, pg.GroupTarifID DESC` + rows, err := r.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + groups := make([]TarifKerjasamaGroup, 0) + for rows.Next() { + var group TarifKerjasamaGroup + var keterangan, kelasDefault, semuaLayanan, hargaRJ, hargaRI sql.NullString + var periodeTarifID sql.NullInt64 + if err := rows.Scan( + &group.GroupTarifID, &group.PerusahaanTarifID, &keterangan, + &group.TanggalBerlaku, &group.TanggalBerakhir, &periodeTarifID, + &kelasDefault, &semuaLayanan, &hargaRJ, &hargaRI, &group.NA, + ); err != nil { + return nil, err + } + group.Keterangan = nullStringPtr(keterangan) + group.KelasDefault = nullStringPtr(kelasDefault) + group.SemuaLayanan = nullStringPtr(semuaLayanan) + group.PeriodeTarifID = nullInt64Ptr(periodeTarifID) + group.DiskonSemuaLayananRJ = nullDecimalPtr(hargaRJ) + group.DiskonSemuaLayananRI = nullDecimalPtr(hargaRI) + group.NamaGroupTarif = "Group Tarif " + strconv.FormatInt(group.GroupTarifID, 10) + if group.Keterangan != nil && *group.Keterangan != "" { + group.NamaGroupTarif = *group.Keterangan + } + group.Aktif = group.NA == "N" + groups = append(groups, group) + } + if err := rows.Err(); err != nil { + _ = rows.Close() + return nil, err + } + if err := rows.Close(); err != nil { + return nil, err + } + + for index := range groups { + groups[index].Layanan, err = r.tarifKerjasamaLayanan(ctx, groups[index].GroupTarifID) + if err != nil { + return nil, err + } + groups[index].JumlahLayanan = len(groups[index].Layanan) + } + return groups, nil +} + +type tarifKerjasamaLayananRow struct { + TarifLayananID int64 + LayananID sql.NullString + NamaLayanan sql.NullString + JenisLayananID sql.NullString + NamaJenis sql.NullString + KelasID sql.NullString + NamaKelas sql.NullString + Harga sql.NullString + TipeDiskonRJ sql.NullString + DiskonRJ sql.NullString + TipeDiskonRI sql.NullString + DiskonRI sql.NullString + TipeShare sql.NullString + ShareDokter sql.NullString + ShareRS sql.NullString + ShareExternal sql.NullString + ShareLain sql.NullString + AsalTarif sql.NullString +} + +func (r MySQLLayananRepository) tarifKerjasamaLayanan(ctx context.Context, groupID int64) ([]TarifKerjasamaLayanan, error) { + tableName := "perusahaan_tarif_layanan_" + strconv.FormatInt(groupID, 10) + query := fmt.Sprintf(`SELECT tarif.TarifLayananID, CAST(tarif.LayananID AS CHAR), + layanan.Nama, CAST(tarif.JenisLayananID AS CHAR), jenis.Nama, + CAST(tarif.KelasID AS CHAR), kelas.Nama, CAST(tarif.Harga AS CHAR), + tarif.TipeDiskon, CAST(tarif.Diskon AS CHAR), tarif.TipeDiskonRI, + CAST(tarif.DiskonRI AS CHAR), tarif.TipeShare, CAST(tarif.ShareDokter AS CHAR), + CAST(tarif.ShareRS AS CHAR), CAST(tarif.ShareExternal AS CHAR), + CAST(tarif.ShareLain AS CHAR), tarif.AsalTarif + FROM %s tarif + LEFT JOIN masterlayanan layanan ON layanan.LayananID = tarif.LayananID + LEFT JOIN jenislayanan jenis ON jenis.JenisLayananID = tarif.JenisLayananID + LEFT JOIN kelas kelas ON kelas.KelasID = tarif.KelasID + WHERE tarif.NA = 'N' + ORDER BY layanan.Nama, tarif.LayananID, tarif.KelasID`, tableName) + rows, err := r.db.QueryContext(ctx, query) + if err != nil { + if isMySQLMissingTable(err) { + return []TarifKerjasamaLayanan{}, nil + } + return nil, err + } + defer rows.Close() + + flatRows := make([]tarifKerjasamaLayananRow, 0) + for rows.Next() { + var row tarifKerjasamaLayananRow + if err := rows.Scan( + &row.TarifLayananID, &row.LayananID, &row.NamaLayanan, + &row.JenisLayananID, &row.NamaJenis, &row.KelasID, &row.NamaKelas, + &row.Harga, &row.TipeDiskonRJ, &row.DiskonRJ, &row.TipeDiskonRI, + &row.DiskonRI, &row.TipeShare, &row.ShareDokter, &row.ShareRS, + &row.ShareExternal, &row.ShareLain, &row.AsalTarif, + ); err != nil { + return nil, err + } + flatRows = append(flatRows, row) + } + if err := rows.Err(); err != nil { + return nil, err + } + return groupTarifKerjasamaLayanan(flatRows), nil +} + +func isMySQLMissingTable(err error) bool { + var mysqlError *mysql.MySQLError + return errors.As(err, &mysqlError) && mysqlError.Number == 1146 +} + +func groupTarifKerjasamaLayanan(rows []tarifKerjasamaLayananRow) []TarifKerjasamaLayanan { + result := make([]TarifKerjasamaLayanan, 0) + indexes := make(map[string]int) + for _, row := range rows { + key := row.LayananID.String + "\x1f" + row.JenisLayananID.String + index, exists := indexes[key] + if !exists { + index = len(result) + indexes[key] = index + result = append(result, TarifKerjasamaLayanan{ + LayananID: nullStringPtr(row.LayananID), NamaLayanan: nullStringPtr(row.NamaLayanan), + JenisLayananID: nullStringPtr(row.JenisLayananID), NamaJenisLayanan: nullStringPtr(row.NamaJenis), + Detail: make([]TarifKerjasamaLayananDetail, 0), + }) + } + namaKelas := row.NamaKelas + if !namaKelas.Valid || namaKelas.String == "" { + namaKelas = row.KelasID + } + result[index].Detail = append(result[index].Detail, TarifKerjasamaLayananDetail{ + TarifLayananID: row.TarifLayananID, KelasID: nullStringPtr(row.KelasID), + NamaKelas: nullStringPtr(namaKelas), Harga: decimalValue(row.Harga), + TipeDiskonRJ: nullStringPtr(row.TipeDiskonRJ), DiskonRJ: decimalValue(row.DiskonRJ), + TipeDiskonRI: nullStringPtr(row.TipeDiskonRI), DiskonRI: decimalValue(row.DiskonRI), + TipeShare: nullStringPtr(row.TipeShare), ShareDokter: decimalValue(row.ShareDokter), + ShareRS: decimalValue(row.ShareRS), ShareExternal: decimalValue(row.ShareExternal), + ShareLain: decimalValue(row.ShareLain), AsalTarif: nullStringPtr(row.AsalTarif), + }) + result[index].JumlahDetail = len(result[index].Detail) + } + return result +} + +func nullDecimalPtr(value sql.NullString) *float64 { + if !value.Valid { + return nil + } + parsed := decimalValue(value) + return &parsed +} + +func decimalValue(value sql.NullString) float64 { + if !value.Valid { + return 0 + } + parsed, _ := strconv.ParseFloat(value.String, 64) + return parsed +} diff --git a/internal/repository/tarif_kerjasama_test.go b/internal/repository/tarif_kerjasama_test.go new file mode 100644 index 0000000..a429bbc --- /dev/null +++ b/internal/repository/tarif_kerjasama_test.go @@ -0,0 +1,81 @@ +package repository + +import ( + "database/sql" + "testing" + + "github.com/go-sql-driver/mysql" +) + +func TestTarifKerjasamaListWhere(t *testing.T) { + tarifID := int64(1098) + where, args := tarifKerjasamaListWhere(TarifKerjasamaListParams{ + Search: "medicare", PerusahaanID: "COMP-001", TarifID: &tarifID, + }) + const expected = "WHERE p.NA = 'N' AND (p.Nama LIKE ? OR p.KodePerusahaan LIKE ?\n\t\t\tOR pt.PerusahaanID LIKE ? OR pt.PenanggungJawab LIKE ?) AND pt.PerusahaanID = ? AND pt.Perusahaan_TarifID = ?" + if where != expected { + t.Fatalf("where=%q", where) + } + if len(args) != 6 || args[0] != "%medicare%" || args[4] != "COMP-001" || args[5] != int64(1098) { + t.Fatalf("args=%v", args) + } +} + +func TestGroupTarifKerjasamaLayanan(t *testing.T) { + rows := []tarifKerjasamaLayananRow{ + tarifKerjasamaTestRow(1, "LAB-001", "KLS-1", "Kelas I", "125000.00"), + tarifKerjasamaTestRow(2, "LAB-001", "KLS-2", "", "115000.00"), + tarifKerjasamaTestRow(3, "LAB-002", "KLS-1", "Kelas I", "50000.00"), + } + + result := groupTarifKerjasamaLayanan(rows) + if len(result) != 2 { + t.Fatalf("services=%d want=2", len(result)) + } + if result[0].LayananID == nil || *result[0].LayananID != "LAB-001" || result[0].JumlahDetail != 2 { + t.Fatalf("first service=%+v", result[0]) + } + if result[0].Detail[0].Harga != 125000 || result[0].Detail[1].NamaKelas == nil || *result[0].Detail[1].NamaKelas != "KLS-2" { + t.Fatalf("details=%+v", result[0].Detail) + } + if result[1].JumlahDetail != 1 || result[1].Detail[0].Harga != 50000 { + t.Fatalf("second service=%+v", result[1]) + } +} + +func TestNullDecimalPtr(t *testing.T) { + if nullDecimalPtr(sql.NullString{}) != nil { + t.Fatal("invalid SQL null must map to nil") + } + value := nullDecimalPtr(sql.NullString{String: "12.50", Valid: true}) + if value == nil || *value != 12.5 { + t.Fatalf("value=%v", value) + } +} + +func TestMySQLMissingDynamicTariffTableError(t *testing.T) { + err := &mysql.MySQLError{Number: 1146, Message: "table does not exist"} + if !isMySQLMissingTable(err) { + t.Fatalf("unexpected MySQL error: %v", err) + } + if isMySQLMissingTable(&mysql.MySQLError{Number: 1054, Message: "unknown column"}) { + t.Fatal("non-1146 MySQL errors must not be treated as a missing table") + } +} + +func tarifKerjasamaTestRow(id int64, layananID, kelasID, namaKelas, harga string) tarifKerjasamaLayananRow { + return tarifKerjasamaLayananRow{ + TarifLayananID: id, + LayananID: sql.NullString{String: layananID, Valid: true}, + NamaLayanan: sql.NullString{String: "Layanan " + layananID, Valid: true}, + JenisLayananID: sql.NullString{String: "LAB", Valid: true}, + NamaJenis: sql.NullString{String: "Laboratorium", Valid: true}, + KelasID: sql.NullString{String: kelasID, Valid: true}, + NamaKelas: sql.NullString{String: namaKelas, Valid: namaKelas != ""}, + Harga: sql.NullString{String: harga, Valid: true}, + DiskonRJ: sql.NullString{String: "10", Valid: true}, + DiskonRI: sql.NullString{String: "5", Valid: true}, + ShareDokter: sql.NullString{String: "25000", Valid: true}, + ShareRS: sql.NullString{String: "100000", Valid: true}, + } +}