From a7102e6b0e683139af670df73008fa6aacc812fa Mon Sep 17 00:00:00 2001 From: Tubagus Fajri Date: Thu, 20 Aug 2026 10:11:12 +0700 Subject: [PATCH] feat: add CPONE package endpoints --- README.md | 4 + cmd/server/main.go | 2 + internal/http/handler.go | 12 +- internal/http/paket.go | 181 ++++++++++++ internal/http/paket_test.go | 133 +++++++++ internal/repository/paket.go | 456 ++++++++++++++++++++++++++++++ internal/repository/paket_test.go | 45 +++ 7 files changed, 832 insertions(+), 1 deletion(-) create mode 100644 internal/http/paket.go create mode 100644 internal/http/paket_test.go create mode 100644 internal/repository/paket.go create mode 100644 internal/repository/paket_test.go diff --git a/README.md b/README.md index bb27cc2..f653adc 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,10 @@ Service Go untuk endpoint: ```text +GET /api/cpone/paket +GET /api/cpone/paket/{id} +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 7909e35..ca8832e 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -44,6 +44,8 @@ func main() { handler := apphttp.NewHandler(repo) 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/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 ed533aa..bbea149 100644 --- a/internal/http/handler.go +++ b/internal/http/handler.go @@ -29,11 +29,17 @@ type LabResultRepository interface { GetLabResult(ctx context.Context, labNumber string) (repository.LabResultOutcome, error) } +type PaketRepository interface { + ListPaket(ctx context.Context, params repository.PaketListParams) (repository.PaketPage, error) + GetPaket(ctx context.Context, id string) (*repository.PaketDetail, error) +} + type Handler struct { layananRepo LayananRepository labRepo LabRegistrationRepository patientRepo PatientRepository labResultRepo LabResultRepository + paketRepo PaketRepository } type CPONERepository interface { @@ -41,10 +47,14 @@ type CPONERepository interface { LabRegistrationRepository PatientRepository LabResultRepository + PaketRepository } func NewHandler(repo CPONERepository) Handler { - return Handler{layananRepo: repo, labRepo: repo, patientRepo: repo, labResultRepo: repo} + return Handler{ + layananRepo: repo, labRepo: repo, patientRepo: repo, + labResultRepo: repo, paketRepo: repo, + } } func (h Handler) MappingHarga(w http.ResponseWriter, r *http.Request) { diff --git a/internal/http/paket.go b/internal/http/paket.go new file mode 100644 index 0000000..d41f849 --- /dev/null +++ b/internal/http/paket.go @@ -0,0 +1,181 @@ +package http + +import ( + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "primaya-api/cpone-middleware/internal/repository" +) + +type PaketValidationResponse struct { + Success bool `json:"success"` + Status string `json:"status"` + Message string `json:"message"` + Data any `json:"data"` +} + +type PaketPaginationResponse struct { + CurrentPage int `json:"current_page"` + Data []repository.PaketSummary `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) ListPaket(w http.ResponseWriter, r *http.Request) { + params, message := parsePaketListParams(r) + if message != "" { + writePaketValidationError(w, message) + return + } + + page, err := h.paketRepo.ListPaket(r.Context(), params) + if err != nil { + WriteJSON(w, http.StatusInternalServerError, Response{ + Success: false, Message: "internal server error", Data: nil, + }) + 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, PaketPaginationResponse{ + 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) GetPaket(w http.ResponseWriter, r *http.Request) { + id := strings.TrimSpace(r.PathValue("id")) + paket, err := h.paketRepo.GetPaket(r.Context(), id) + if err != nil { + WriteJSON(w, http.StatusInternalServerError, Response{ + Success: false, Message: "internal server error", Data: nil, + }) + return + } + if paket == nil { + WriteJSON(w, http.StatusNotFound, Response{ + Success: false, Message: "Data tidak ditemukan", Data: nil, + }) + return + } + + WriteJSON(w, http.StatusOK, Response{Success: true, Message: "ok", Data: paket}) +} + +func parsePaketListParams(r *http.Request) (repository.PaketListParams, string) { + query := r.URL.Query() + params := repository.PaketListParams{ + Search: query.Get("search"), Code: query.Get("code"), + CareType: query.Get("care_type"), Active: query.Get("active"), + StartDate: query.Get("start_date"), EndDate: query.Get("end_date"), + Status: 2, Page: 1, PerPage: 15, + } + + if params.CareType != "" && params.CareType != "RI" && params.CareType != "RJ" { + return params, "The selected care type is invalid." + } + if raw, exists := firstQueryValue(r, "status"); exists && raw != "" { + value, err := strconv.Atoi(raw) + if err != nil { + return params, "The status must be an integer." + } + if value < -1 || value > 2 { + return params, "The selected status is invalid." + } + params.Status = value + } + if params.Active != "" && params.Active != "N" && params.Active != "Y" { + return params, "The selected active is invalid." + } + if params.StartDate != "" { + if _, err := time.Parse("2006-01-02", params.StartDate); err != nil { + return params, "The start date does not match the format Y-m-d." + } + } + if params.EndDate != "" { + end, err := time.Parse("2006-01-02", params.EndDate) + if err != nil { + return params, "The end date does not match the format Y-m-d." + } + if params.StartDate != "" { + start, _ := time.Parse("2006-01-02", params.StartDate) + if end.Before(start) { + return params, "The end date must be a date after or equal to start date." + } + } + } + if raw, exists := firstQueryValue(r, "page"); exists && raw != "" { + value, err := strconv.Atoi(raw) + if err != nil { + return params, "The page must be an integer." + } + if 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 { + return params, "The per page must be an integer." + } + if value < 1 || value > 100 { + return params, "The per page must be between 1 and 100." + } + params.PerPage = value + } + + return params, "" +} + +func currentRequestURL(r *http.Request, page int) string { + scheme := "http" + if r.TLS != nil { + scheme = "https" + } + if forwarded := r.Header.Get("X-Forwarded-Proto"); forwarded != "" { + scheme = strings.TrimSpace(strings.Split(forwarded, ",")[0]) + } + host := r.Host + if host == "" { + host = "localhost" + } + return (&url.URL{Scheme: scheme, Host: host, Path: r.URL.Path, RawQuery: "page=" + strconv.Itoa(page)}).String() +} + +func writePaketValidationError(w http.ResponseWriter, message string) { + WriteJSON(w, http.StatusUnprocessableEntity, PaketValidationResponse{ + Success: false, Status: "validation_error", Message: message, Data: nil, + }) +} diff --git a/internal/http/paket_test.go b/internal/http/paket_test.go new file mode 100644 index 0000000..436ce86 --- /dev/null +++ b/internal/http/paket_test.go @@ -0,0 +1,133 @@ +package http + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "primaya-api/cpone-middleware/internal/repository" +) + +type fakePaketRepository struct { + page repository.PaketPage + detail *repository.PaketDetail + err error + listParams repository.PaketListParams + detailID string +} + +func (f *fakePaketRepository) ListPaket(_ context.Context, params repository.PaketListParams) (repository.PaketPage, error) { + f.listParams = params + return f.page, f.err +} + +func (f *fakePaketRepository) GetPaket(_ context.Context, id string) (*repository.PaketDetail, error) { + f.detailID = id + return f.detail, f.err +} + +func TestListPaketResponseParity(t *testing.T) { + kodeCorp := "ANP" + jenisPaket := "Medical Check Up" + status := "approved" + repo := &fakePaketRepository{page: repository.PaketPage{ + Total: 188, + Items: []repository.PaketSummary{{ + PaketID: "DISP-260800007", KodeCorp: &kodeCorp, Nama: "PAKET ANTI PLENGER", + JenisPaketID: "MCU", JenisPaket: &jenisPaket, Harga: 500000, + RawatJalan: true, TglDari: "2026-08-05", TglSampai: "2038-08-31", + KelasID: "KelasI", JumlahHari: 1, StatusID: 2, + Status: &status, NA: "N", Aktif: true, Keterangan: "GRWGRW", + }}, + }} + handler := Handler{paketRepo: repo} + request := httptest.NewRequest(http.MethodGet, "http://127.0.0.1:8282/api/cpone/paket?search=PLENGER&care_type=RJ&active=N&page=1&per_page=15", nil) + recorder := httptest.NewRecorder() + + handler.ListPaket(recorder, request) + + if recorder.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String()) + } + if repo.listParams.Status != 2 || repo.listParams.Search != "PLENGER" || repo.listParams.CareType != "RJ" || repo.listParams.Active != "N" { + t.Fatalf("params mismatch: %+v", repo.listParams) + } + const expected = `{"current_page":1,"data":[{"PaketID":"DISP-260800007","KodeCorp":"ANP","Nama":"PAKET ANTI PLENGER","JenisPaketID":"MCU","JenisPaket":"Medical Check Up","Harga":500000,"RawatInap":false,"RawatJalan":true,"MultiVisit":false,"TglDari":"2026-08-05","TglSampai":"2038-08-31","KelasID":"KelasI","Kelas":null,"JumlahHari":1,"StatusID":2,"Status":"approved","NA":"N","Aktif":true,"Keterangan":"GRWGRW"}],"first_page_url":"","from":1,"last_page":13,"last_page_url":"","links":{},"next_page_url":"http://127.0.0.1:8282/api/cpone/paket?page=2","total":188,"per_page":15}` + "\n" + if recorder.Body.String() != expected { + t.Fatalf("body mismatch\n got: %s\nwant: %s", recorder.Body.String(), expected) + } +} + +func TestListPaketValidationParity(t *testing.T) { + tests := []struct { + query, message string + }{ + {"?care_type=IGD", "The selected care type is invalid."}, + {"?status=x", "The status must be an integer."}, + {"?status=3", "The selected status is invalid."}, + {"?active=X", "The selected active is invalid."}, + {"?start_date=20-08-2026", "The start date does not match the format Y-m-d."}, + {"?start_date=2026-08-20&end_date=2026-08-19", "The end date must be a date after or equal to start date."}, + {"?page=0", "The page must be at least 1."}, + {"?per_page=101", "The per page must be between 1 and 100."}, + } + for _, test := range tests { + t.Run(test.query, func(t *testing.T) { + handler := Handler{paketRepo: &fakePaketRepository{}} + request := httptest.NewRequest(http.MethodGet, "/api/cpone/paket"+test.query, nil) + recorder := httptest.NewRecorder() + handler.ListPaket(recorder, request) + if recorder.Code != http.StatusUnprocessableEntity { + t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String()) + } + expected := `{"success":false,"status":"validation_error","message":` + strconvQuote(test.message) + `,"data":null}` + "\n" + if recorder.Body.String() != expected { + t.Fatalf("body mismatch\n got: %s\nwant: %s", recorder.Body.String(), expected) + } + }) + } +} + +func TestGetPaketResponseAndNotFoundParity(t *testing.T) { + detail := &repository.PaketDetail{ + PaketID: "DISP-260800002", Nama: "Test paket", JenisPaket: "DIA", + RI: 1, TglDari: "2026-08-06", TglSampai: "2026-08-31", KelasID: "KelasII", + JumlahHari: 1, TotalHarga: "1550913.00", StatusID: 2, LoginBuat: "API_CRM", + TanggalBuat: "2026-08-05T10:05:03.000000Z", LoginEdit: "API_CRM", + TanggalEdit: "2026-08-19T06:05:20.000000Z", NA: "N", + Layanan: []repository.PaketLayanan{}, Operasi: []repository.PaketOperasi{}, + Item: []repository.PaketItem{}, Kamar: []repository.PaketKamar{}, + } + repo := &fakePaketRepository{detail: detail} + handler := Handler{paketRepo: repo} + request := httptest.NewRequest(http.MethodGet, "/api/cpone/paket/DISP-260800002", nil) + request.SetPathValue("id", "DISP-260800002") + recorder := httptest.NewRecorder() + handler.GetPaket(recorder, request) + if recorder.Code != http.StatusOK || repo.detailID != "DISP-260800002" { + t.Fatalf("status=%d id=%q body=%s", recorder.Code, repo.detailID, recorder.Body.String()) + } + + notFoundHandler := Handler{paketRepo: &fakePaketRepository{}} + notFoundRequest := httptest.NewRequest(http.MethodGet, "/api/cpone/paket/UNKNOWN", nil) + notFoundRequest.SetPathValue("id", "UNKNOWN") + notFoundRecorder := httptest.NewRecorder() + notFoundHandler.GetPaket(notFoundRecorder, notFoundRequest) + const expected = `{"success":false,"message":"Data tidak ditemukan","data":null}` + "\n" + if notFoundRecorder.Code != http.StatusNotFound || notFoundRecorder.Body.String() != expected { + t.Fatalf("status=%d body=%s", notFoundRecorder.Code, notFoundRecorder.Body.String()) + } +} + +func TestPaketRepositoryErrorIsHidden(t *testing.T) { + handler := Handler{paketRepo: &fakePaketRepository{err: errors.New("database unavailable")}} + request := httptest.NewRequest(http.MethodGet, "/api/cpone/paket", nil) + recorder := httptest.NewRecorder() + handler.ListPaket(recorder, request) + const expected = `{"success":false,"message":"internal server error","data":null}` + "\n" + if recorder.Code != http.StatusInternalServerError || recorder.Body.String() != expected { + t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String()) + } +} diff --git a/internal/repository/paket.go b/internal/repository/paket.go new file mode 100644 index 0000000..453688e --- /dev/null +++ b/internal/repository/paket.go @@ -0,0 +1,456 @@ +package repository + +import ( + "context" + "database/sql" + "math" + "strconv" + "strings" + "time" +) + +type PaketListParams struct { + Search string + Code string + CareType string + Status int + Active string + StartDate string + EndDate string + Page int + PerPage int +} + +type PaketPage struct { + Items []PaketSummary + Total int +} + +type PaketSummary struct { + PaketID string `json:"PaketID"` + KodeCorp *string `json:"KodeCorp"` + Nama string `json:"Nama"` + JenisPaketID string `json:"JenisPaketID"` + JenisPaket *string `json:"JenisPaket"` + Harga int64 `json:"Harga"` + RawatInap bool `json:"RawatInap"` + RawatJalan bool `json:"RawatJalan"` + MultiVisit bool `json:"MultiVisit"` + TglDari string `json:"TglDari"` + TglSampai string `json:"TglSampai"` + KelasID string `json:"KelasID"` + Kelas *string `json:"Kelas"` + JumlahHari int `json:"JumlahHari"` + StatusID int `json:"StatusID"` + Status *string `json:"Status"` + NA string `json:"NA"` + Aktif bool `json:"Aktif"` + Keterangan string `json:"Keterangan"` +} + +type PaketDetail struct { + PaketID string `json:"PaketID"` + Nama string `json:"Nama"` + JenisPaket string `json:"JenisPaket"` + RJ int `json:"RJ"` + RI int `json:"RI"` + TglDari string `json:"TglDari"` + TglSampai string `json:"TglSampai"` + MultiVisit int `json:"MultiVisit"` + HeaderPaket string `json:"HeaderPaket"` + KelasID string `json:"KelasID"` + JumlahHari int `json:"JumlahHari"` + TotalHarga string `json:"TotalHarga"` + Keterangan string `json:"Keterangan"` + StatusID int `json:"StatusID"` + LoginBuat string `json:"LoginBuat"` + TanggalBuat string `json:"TanggalBuat"` + LoginEdit string `json:"LoginEdit"` + TanggalEdit string `json:"TanggalEdit"` + NA string `json:"NA"` + Layanan []PaketLayanan `json:"layanan"` + Operasi []PaketOperasi `json:"operasi"` + Item []PaketItem `json:"item"` + Kamar []PaketKamar `json:"kamar"` +} + +type PaketLayanan struct { + PaketLayananID int64 `json:"PaketLayananID"` + LayananID string `json:"LayananID"` + NamaLayanan *string `json:"NamaLayanan"` + NamaDepartemen *string `json:"NamaDepartemen"` + Jumlah string `json:"Jumlah"` + JumlahHari int `json:"JumlahHari"` + Harga string `json:"Harga"` + Diskon string `json:"Diskon"` + TipeDiskon string `json:"TipeDiskon"` + ShareRS string `json:"ShareRS"` + ShareDokter string `json:"ShareDokter"` + Subtotal string `json:"Subtotal"` +} + +type PaketOperasi struct { + PaketOperasiHeaderID int64 `json:"PaketOperasiHeaderID"` + NamaTindakan *string `json:"NamaTindakan"` + NamaGolongan *string `json:"NamaGolongan"` + NamaJenisAnestesi *string `json:"NamaJenisAnestesi"` + NamaKasusOperasi *string `json:"NamaKasusOperasi"` + EstimasiWaktu string `json:"EstimasiWaktu"` + Keterangan string `json:"Keterangan"` +} + +type PaketItem struct { + PaketItemID int64 `json:"PaketItemID"` + ItemID string `json:"ItemID"` + NamaItem *string `json:"NamaItem"` + NamaDepartemen *string `json:"NamaDepartemen"` + Jumlah string `json:"Jumlah"` + NamaSatuan *string `json:"NamaSatuan"` + Harga string `json:"Harga"` + Diskon string `json:"Diskon"` + TipeDiskon string `json:"TipeDiskon"` + SubTotal string `json:"SubTotal"` +} + +type PaketKamar struct { + PaketTTID int64 `json:"PaketTTID"` + NamaDepartemen *string `json:"NamaDepartemen"` + NamaKelas *string `json:"NamaKelas"` + Jumlah string `json:"Jumlah"` + Harga string `json:"Harga"` + TipeDiskon string `json:"TipeDiskon"` + Diskon float64 `json:"Diskon"` + SubTotal string `json:"SubTotal"` +} + +func (r MySQLLayananRepository) ListPaket(ctx context.Context, params PaketListParams) (PaketPage, error) { + where, args := paketListWhere(params) + var total int + if err := r.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM paket_header ph "+where, args...).Scan(&total); err != nil { + return PaketPage{}, err + } + + query := ` + SELECT ph.PaketID, + (SELECT pch.KodeCorp FROM paket_corp_header pch + WHERE pch.PaketID = ph.PaketID ORDER BY pch.TanggalBuat DESC LIMIT 1), + ph.Nama, ph.JenisPaket, jp.Nama, ph.TotalHarga, ph.RI, ph.RJ, + ph.MultiVisit, CAST(ph.TglDari AS CHAR), CAST(ph.TglSampai AS CHAR), + ph.KelasID, ph.JumlahHari, ph.StatusID, ph.NA, ph.Keterangan + FROM paket_header ph + LEFT JOIN jenispaket jp ON jp.JenisPaketID = ph.JenisPaket + ` + where + ` + ORDER BY ph.TanggalBuat DESC, ph.PaketID DESC + 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 PaketPage{}, err + } + defer rows.Close() + + items := make([]PaketSummary, 0) + for rows.Next() { + var item PaketSummary + var kodeCorp, jenisPaket sql.NullString + var harga float64 + var ri, rj, multiVisit int + if err := rows.Scan( + &item.PaketID, &kodeCorp, &item.Nama, &item.JenisPaketID, &jenisPaket, + &harga, &ri, &rj, &multiVisit, &item.TglDari, &item.TglSampai, + &item.KelasID, &item.JumlahHari, &item.StatusID, &item.NA, &item.Keterangan, + ); err != nil { + return PaketPage{}, err + } + item.KodeCorp = nullStringPtr(kodeCorp) + item.JenisPaket = nullStringPtr(jenisPaket) + item.Harga = int64(math.Round(harga)) + item.RawatInap = ri != 0 + item.RawatJalan = rj != 0 + item.MultiVisit = multiVisit != 0 + item.Status = paketStatus(item.StatusID) + item.Aktif = item.NA == "N" + items = append(items, item) + } + if err := rows.Err(); err != nil { + return PaketPage{}, err + } + return PaketPage{Items: items, Total: total}, nil +} + +func paketListWhere(params PaketListParams) (string, []any) { + conditions := []string{"ph.StatusID = ?"} + args := []any{params.Status} + if params.Active != "" { + conditions = append(conditions, "ph.NA = ?") + args = append(args, params.Active) + } + if params.Search != "" { + conditions = append(conditions, "ph.Nama LIKE ?") + args = append(args, "%"+params.Search+"%") + } + if params.Code != "" { + conditions = append(conditions, "ph.PaketID LIKE ?") + args = append(args, "%"+params.Code+"%") + } + if params.CareType != "" { + conditions = append(conditions, "ph."+params.CareType+" = 1") + } + if params.StartDate != "" { + conditions = append(conditions, "ph.TglSampai >= ?") + args = append(args, params.StartDate) + } + if params.EndDate != "" { + conditions = append(conditions, "ph.TglDari <= ?") + args = append(args, params.EndDate) + } + return "WHERE " + strings.Join(conditions, " AND "), args +} + +func paketStatus(status int) *string { + statuses := map[int]string{-1: "rejected", 0: "draft", 1: "submitted", 2: "approved"} + value, ok := statuses[status] + if !ok { + return nil + } + return &value +} + +func (r MySQLLayananRepository) GetPaket(ctx context.Context, id string) (*PaketDetail, error) { + row := r.db.QueryRowContext(ctx, ` + SELECT PaketID, Nama, JenisPaket, RJ, RI, CAST(TglDari AS CHAR), CAST(TglSampai AS CHAR), + MultiVisit, HeaderPaket, KelasID, JumlahHari, CAST(TotalHarga AS CHAR), Keterangan, + StatusID, LoginBuat, CAST(TanggalBuat AS CHAR), LoginEdit, CAST(TanggalEdit AS CHAR), NA + FROM paket_header WHERE PaketID = ? LIMIT 1`, id) + var result PaketDetail + var tanggalBuat, tanggalEdit string + if err := row.Scan( + &result.PaketID, &result.Nama, &result.JenisPaket, &result.RJ, &result.RI, + &result.TglDari, &result.TglSampai, &result.MultiVisit, &result.HeaderPaket, + &result.KelasID, &result.JumlahHari, &result.TotalHarga, &result.Keterangan, + &result.StatusID, &result.LoginBuat, &tanggalBuat, &result.LoginEdit, &tanggalEdit, &result.NA, + ); err != nil { + if err == sql.ErrNoRows { + return nil, nil + } + return nil, err + } + result.TanggalBuat = laravelDateJSON(tanggalBuat) + result.TanggalEdit = laravelDateJSON(tanggalEdit) + + var err error + if result.Layanan, err = r.paketLayanan(ctx, id); err != nil { + return nil, err + } + if result.Operasi, err = r.paketOperasi(ctx, id); err != nil { + return nil, err + } + if result.Item, err = r.paketItem(ctx, id); err != nil { + return nil, err + } + if result.Kamar, err = r.paketKamar(ctx, id); err != nil { + return nil, err + } + return &result, nil +} + +func (r MySQLLayananRepository) paketLayanan(ctx context.Context, id string) ([]PaketLayanan, error) { + rows, err := r.db.QueryContext(ctx, ` + SELECT pl.PaketLayananID, pl.LayananID, ml.Nama, d.Nama, + CAST(pl.Jumlah AS CHAR), pl.JumlahHari, CAST(pl.Harga AS CHAR), + CAST(pl.Diskon AS CHAR), pl.TipeDiskon, CAST(pl.ShareRS AS CHAR), + CAST(pl.ShareDokter AS CHAR), CAST(pl.Subtotal AS CHAR) + FROM paket_layanan pl + LEFT JOIN masterlayanan ml ON ml.LayananID = pl.LayananID + LEFT JOIN departemen d ON d.DepartemenID = pl.DepartemenID AND d.NA = 'N' + WHERE pl.PaketID = ? AND pl.NA = 'N' + ORDER BY pl.PaketLayananID`, id) + if err != nil { + return nil, err + } + defer rows.Close() + result := make([]PaketLayanan, 0) + for rows.Next() { + var item PaketLayanan + var namaLayanan, namaDepartemen sql.NullString + var harga, shareRS, shareDokter, subtotal string + if err := rows.Scan( + &item.PaketLayananID, &item.LayananID, &namaLayanan, &namaDepartemen, + &item.Jumlah, &item.JumlahHari, &harga, &item.Diskon, &item.TipeDiskon, + &shareRS, &shareDokter, &subtotal, + ); err != nil { + return nil, err + } + item.NamaLayanan = nullStringPtr(namaLayanan) + item.NamaDepartemen = nullStringPtr(namaDepartemen) + item.Harga = phpNumberFormat(parseDecimal(harga), 0) + item.ShareRS = phpNumberFormat(parseDecimal(shareRS), 2) + item.ShareDokter = phpNumberFormat(parseDecimal(shareDokter), 0) + item.Subtotal = phpNumberFormat(parseDecimal(subtotal), 0) + result = append(result, item) + } + return result, rows.Err() +} + +func (r MySQLLayananRepository) paketOperasi(ctx context.Context, id string) ([]PaketOperasi, error) { + rows, err := r.db.QueryContext(ctx, ` + SELECT po.PaketOperasiHeaderID, mt.Nama, go.Nama, ja.Nama, ko.Nama, + po.EstimasiWaktu, po.Keterangan + FROM paket_operasi_header po + LEFT JOIN mastertindakan mt ON mt.TindakanID = po.TindakanID + LEFT JOIN golonganoperasi go ON go.GolonganOperasiID = po.GolonganOperasiID + LEFT JOIN jenisanestesi ja ON ja.JenisAnestesiID = po.JenisAnestesiID + LEFT JOIN kasusoperasi ko ON ko.KasusOperasiID = po.KasusOperasiID + WHERE po.PaketID = ? AND po.NA = 'N' + ORDER BY po.PaketOperasiHeaderID`, id) + if err != nil { + return nil, err + } + defer rows.Close() + result := make([]PaketOperasi, 0) + for rows.Next() { + var item PaketOperasi + var tindakan, golongan, anestesi, kasus sql.NullString + if err := rows.Scan( + &item.PaketOperasiHeaderID, &tindakan, &golongan, &anestesi, &kasus, + &item.EstimasiWaktu, &item.Keterangan, + ); err != nil { + return nil, err + } + item.NamaTindakan = nullStringPtr(tindakan) + item.NamaGolongan = nullStringPtr(golongan) + item.NamaJenisAnestesi = nullStringPtr(anestesi) + item.NamaKasusOperasi = nullStringPtr(kasus) + result = append(result, item) + } + return result, rows.Err() +} + +func (r MySQLLayananRepository) paketItem(ctx context.Context, id string) ([]PaketItem, error) { + rows, err := r.db.QueryContext(ctx, ` + SELECT pi.PaketItemID, pi.ItemID, mi.Nama, d.Nama, CAST(pi.Jumlah AS CHAR), s.Nama, + CAST(pi.Harga AS CHAR), CAST(pi.Diskon AS CHAR), pi.TipeDiskon, + pi.SatuanID, pi.SatuanDasarID, + COALESCE((SELECT ks.NilaiKonversi FROM konversisatuan ks + WHERE ks.ItemID = pi.ItemID AND ks.SatuanTujuan = pi.SatuanDasarID + AND ks.SatuanAsal = pi.SatuanID AND ks.NA = 'N' LIMIT 1), 1) + FROM paket_item pi + LEFT JOIN masteritem mi ON mi.ItemID = pi.ItemID AND mi.NA = 'N' + LEFT JOIN departemen d ON d.DepartemenID = pi.DepartemenID AND d.NA = 'N' + LEFT JOIN satuan s ON s.SatuanID = pi.SatuanID + WHERE pi.PaketID = ? AND pi.NA = 'N' + ORDER BY pi.PaketItemID`, id) + if err != nil { + return nil, err + } + defer rows.Close() + result := make([]PaketItem, 0) + for rows.Next() { + var item PaketItem + var namaItem, namaDepartemen, namaSatuan sql.NullString + var harga, satuanID, satuanDasarID string + var konversi float64 + if err := rows.Scan( + &item.PaketItemID, &item.ItemID, &namaItem, &namaDepartemen, &item.Jumlah, + &namaSatuan, &harga, &item.Diskon, &item.TipeDiskon, + &satuanID, &satuanDasarID, &konversi, + ); err != nil { + return nil, err + } + item.NamaItem = nullStringPtr(namaItem) + item.NamaDepartemen = nullStringPtr(namaDepartemen) + item.NamaSatuan = nullStringPtr(namaSatuan) + hargaValue := parseDecimal(harga) + diskon := parseDecimal(item.Diskon) + nDisc := diskon + if item.TipeDiskon == "Percentage" { + nDisc = hargaValue * (diskon / 100) + } + if satuanDasarID == satuanID { + konversi = 1 + } + item.Harga = phpNumberFormat(hargaValue, 2) + item.SubTotal = phpNumberFormat((hargaValue-nDisc)*parseDecimal(item.Jumlah)*konversi, 2) + result = append(result, item) + } + return result, rows.Err() +} + +func (r MySQLLayananRepository) paketKamar(ctx context.Context, id string) ([]PaketKamar, error) { + rows, err := r.db.QueryContext(ctx, ` + SELECT pt.PaketTTID, d.Nama, k.Nama, CAST(pt.Jumlah AS CHAR), + CAST(pt.Harga AS CHAR), pt.TipeDiskon, CAST(pt.Diskon AS CHAR) + FROM paket_tt pt + LEFT JOIN departemen d ON d.DepartemenID = pt.DepartemenID AND d.NA = 'N' + LEFT JOIN kelas k ON k.KelasID = pt.KelasID AND k.NA = 'N' + WHERE pt.PaketID = ? + ORDER BY pt.PaketTTID`, id) + if err != nil { + return nil, err + } + defer rows.Close() + result := make([]PaketKamar, 0) + for rows.Next() { + var item PaketKamar + var namaDepartemen, namaKelas sql.NullString + var harga, diskon string + if err := rows.Scan( + &item.PaketTTID, &namaDepartemen, &namaKelas, &item.Jumlah, + &harga, &item.TipeDiskon, &diskon, + ); err != nil { + return nil, err + } + item.NamaDepartemen = nullStringPtr(namaDepartemen) + item.NamaKelas = nullStringPtr(namaKelas) + hargaValue := parseDecimal(harga) + diskonValue := parseDecimal(diskon) + item.Diskon = diskonValue + if item.TipeDiskon == "Percentage" { + item.Diskon = hargaValue * (diskonValue / 100) + } + item.Harga = phpNumberFormat(hargaValue, 2) + item.SubTotal = phpNumberFormat((hargaValue-item.Diskon)*parseDecimal(item.Jumlah), 0) + result = append(result, item) + } + return result, rows.Err() +} + +func parseDecimal(value string) float64 { + parsed, _ := strconv.ParseFloat(value, 64) + return parsed +} + +func phpNumberFormat(value float64, decimals int) string { + power := math.Pow10(decimals) + value = math.Round(value*power) / power + formatted := strconv.FormatFloat(value, 'f', decimals, 64) + parts := strings.SplitN(formatted, ".", 2) + integer := parts[0] + sign := "" + if strings.HasPrefix(integer, "-") { + sign = "-" + integer = strings.TrimPrefix(integer, "-") + } + for index := len(integer) - 3; index > 0; index -= 3 { + integer = integer[:index] + "," + integer[index:] + } + if len(parts) == 2 { + return sign + integer + "." + parts[1] + } + return sign + integer +} + +func laravelDateJSON(value string) string { + if strings.HasPrefix(value, "0000-00-00") { + return "-000001-11-29T16:52:48.000000Z" + } + location, err := time.LoadLocation("Asia/Jakarta") + if err != nil { + return value + } + parsed, err := time.ParseInLocation("2006-01-02 15:04:05", value, location) + if err != nil { + return value + } + return parsed.UTC().Format("2006-01-02T15:04:05.000000Z") +} diff --git a/internal/repository/paket_test.go b/internal/repository/paket_test.go new file mode 100644 index 0000000..58499bc --- /dev/null +++ b/internal/repository/paket_test.go @@ -0,0 +1,45 @@ +package repository + +import "testing" + +func TestPaketListWhereParity(t *testing.T) { + where, args := paketListWhere(PaketListParams{ + Search: "MCU", Code: "DISP", CareType: "RJ", Status: 2, + Active: "N", StartDate: "2026-08-01", EndDate: "2026-08-31", + }) + const expected = "WHERE ph.StatusID = ? AND ph.NA = ? AND ph.Nama LIKE ? AND ph.PaketID LIKE ? AND ph.RJ = 1 AND ph.TglSampai >= ? AND ph.TglDari <= ?" + if where != expected { + t.Fatalf("where=%q", where) + } + if len(args) != 6 || args[0] != 2 || args[1] != "N" || args[2] != "%MCU%" || args[3] != "%DISP%" { + t.Fatalf("args=%v", args) + } +} + +func TestPHPNumberFormatParity(t *testing.T) { + tests := []struct { + value float64 + decimals int + expected string + }{ + {500000, 0, "500,000"}, + {1609.5, 2, "1,609.50"}, + {1448.55, 2, "1,448.55"}, + {0, 2, "0.00"}, + {-1234.5, 0, "-1,235"}, + } + for _, test := range tests { + if actual := phpNumberFormat(test.value, test.decimals); actual != test.expected { + t.Fatalf("phpNumberFormat(%v, %d)=%q want=%q", test.value, test.decimals, actual, test.expected) + } + } +} + +func TestLaravelDateJSONParity(t *testing.T) { + if actual := laravelDateJSON("2026-08-05 17:05:03"); actual != "2026-08-05T10:05:03.000000Z" { + t.Fatalf("date=%q", actual) + } + if actual := laravelDateJSON("0000-00-00 00:00:00"); actual != "-000001-11-29T16:52:48.000000Z" { + t.Fatalf("zero date=%q", actual) + } +}