feat: add CPONE package endpoints

This commit is contained in:
2026-08-20 10:11:12 +07:00
parent 21196d8227
commit a7102e6b0e
7 changed files with 832 additions and 1 deletions

View File

@@ -3,6 +3,10 @@
Service Go untuk endpoint: Service Go untuk endpoint:
```text ```text
GET /api/cpone/paket
GET /api/cpone/paket/{id}
Authorization: Bearer <your-secure-token>
GET /api/cpone/layanan-mapping-harga?periodeTarifID=10&layananID=6413,6414,1309 GET /api/cpone/layanan-mapping-harga?periodeTarifID=10&layananID=6413,6414,1309
Authorization: Bearer <your-secure-token> Authorization: Bearer <your-secure-token>

View File

@@ -44,6 +44,8 @@ func main() {
handler := apphttp.NewHandler(repo) handler := apphttp.NewHandler(repo)
mux := http.NewServeMux() 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("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/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))) mux.Handle("POST /api/cpone/patients/lab-registration", apphttp.BearerAuth(cfg.BearerToken, http.HandlerFunc(handler.RegisterLab)))

View File

@@ -29,11 +29,17 @@ type LabResultRepository interface {
GetLabResult(ctx context.Context, labNumber string) (repository.LabResultOutcome, error) 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 { type Handler struct {
layananRepo LayananRepository layananRepo LayananRepository
labRepo LabRegistrationRepository labRepo LabRegistrationRepository
patientRepo PatientRepository patientRepo PatientRepository
labResultRepo LabResultRepository labResultRepo LabResultRepository
paketRepo PaketRepository
} }
type CPONERepository interface { type CPONERepository interface {
@@ -41,10 +47,14 @@ type CPONERepository interface {
LabRegistrationRepository LabRegistrationRepository
PatientRepository PatientRepository
LabResultRepository LabResultRepository
PaketRepository
} }
func NewHandler(repo CPONERepository) Handler { 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) { func (h Handler) MappingHarga(w http.ResponseWriter, r *http.Request) {

181
internal/http/paket.go Normal file
View File

@@ -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,
})
}

133
internal/http/paket_test.go Normal file
View File

@@ -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())
}
}

View File

@@ -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")
}

View File

@@ -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)
}
}