From b9eb9343640373761c12c1ef030c06f6c6405515 Mon Sep 17 00:00:00 2001 From: Tubagus Fajri Date: Fri, 21 Aug 2026 10:21:47 +0700 Subject: [PATCH] feat: add package base prices --- internal/http/paket_test.go | 17 ++- internal/repository/paket.go | 172 +++++++++++++++++++++++++++--- internal/repository/paket_test.go | 53 ++++++++- 3 files changed, 225 insertions(+), 17 deletions(-) diff --git a/internal/http/paket_test.go b/internal/http/paket_test.go index 436ce86..22ba10e 100644 --- a/internal/http/paket_test.go +++ b/internal/http/paket_test.go @@ -5,6 +5,7 @@ import ( "errors" "net/http" "net/http/httptest" + "strings" "testing" "primaya-api/cpone-middleware/internal/repository" @@ -97,8 +98,10 @@ func TestGetPaketResponseAndNotFoundParity(t *testing.T) { 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{}, + Layanan: []repository.PaketLayanan{{HargaDasar: "100,000"}}, + Operasi: []repository.PaketOperasi{{HargaDasar: "250,000.00"}}, + Item: []repository.PaketItem{{HargaDasar: "50,000.00"}}, + Kamar: []repository.PaketKamar{{HargaDasar: "200,000.00"}}, } repo := &fakePaketRepository{detail: detail} handler := Handler{paketRepo: repo} @@ -109,6 +112,16 @@ func TestGetPaketResponseAndNotFoundParity(t *testing.T) { if recorder.Code != http.StatusOK || repo.detailID != "DISP-260800002" { t.Fatalf("status=%d id=%q body=%s", recorder.Code, repo.detailID, recorder.Body.String()) } + for _, expected := range []string{ + `"HargaDasar":"100,000"`, + `"HargaDasar":"250,000.00"`, + `"HargaDasar":"50,000.00"`, + `"HargaDasar":"200,000.00"`, + } { + if !strings.Contains(recorder.Body.String(), expected) { + t.Fatalf("response does not contain %s: %s", expected, recorder.Body.String()) + } + } notFoundHandler := Handler{paketRepo: &fakePaketRepository{}} notFoundRequest := httptest.NewRequest(http.MethodGet, "/api/cpone/paket/UNKNOWN", nil) diff --git a/internal/repository/paket.go b/internal/repository/paket.go index 453688e..29caf24 100644 --- a/internal/repository/paket.go +++ b/internal/repository/paket.go @@ -3,6 +3,7 @@ package repository import ( "context" "database/sql" + "fmt" "math" "strconv" "strings" @@ -82,6 +83,7 @@ type PaketLayanan struct { Jumlah string `json:"Jumlah"` JumlahHari int `json:"JumlahHari"` Harga string `json:"Harga"` + HargaDasar string `json:"HargaDasar"` Diskon string `json:"Diskon"` TipeDiskon string `json:"TipeDiskon"` ShareRS string `json:"ShareRS"` @@ -97,6 +99,7 @@ type PaketOperasi struct { NamaKasusOperasi *string `json:"NamaKasusOperasi"` EstimasiWaktu string `json:"EstimasiWaktu"` Keterangan string `json:"Keterangan"` + HargaDasar string `json:"HargaDasar"` } type PaketItem struct { @@ -107,6 +110,7 @@ type PaketItem struct { Jumlah string `json:"Jumlah"` NamaSatuan *string `json:"NamaSatuan"` Harga string `json:"Harga"` + HargaDasar string `json:"HargaDasar"` Diskon string `json:"Diskon"` TipeDiskon string `json:"TipeDiskon"` SubTotal string `json:"SubTotal"` @@ -118,6 +122,7 @@ type PaketKamar struct { NamaKelas *string `json:"NamaKelas"` Jumlah string `json:"Jumlah"` Harga string `json:"Harga"` + HargaDasar string `json:"HargaDasar"` TipeDiskon string `json:"TipeDiskon"` Diskon float64 `json:"Diskon"` SubTotal string `json:"SubTotal"` @@ -238,14 +243,18 @@ func (r MySQLLayananRepository) GetPaket(ctx context.Context, id string) (*Paket result.TanggalBuat = laravelDateJSON(tanggalBuat) result.TanggalEdit = laravelDateJSON(tanggalEdit) - var err error - if result.Layanan, err = r.paketLayanan(ctx, id); err != nil { + tariffTable, err := r.activePaketTariffTable(ctx) + if err != nil { return nil, err } - if result.Operasi, err = r.paketOperasi(ctx, id); err != nil { + pricing := newPaketBasePricing() + if result.Layanan, err = r.paketLayanan(ctx, id, result.KelasID, tariffTable, pricing); err != nil { return nil, err } - if result.Item, err = r.paketItem(ctx, id); err != nil { + if result.Item, err = r.paketItem(ctx, id, pricing); err != nil { + return nil, err + } + if result.Operasi, err = r.paketOperasi(ctx, id, pricing); err != nil { return nil, err } if result.Kamar, err = r.paketKamar(ctx, id); err != nil { @@ -254,17 +263,85 @@ func (r MySQLLayananRepository) GetPaket(ctx context.Context, id string) (*Paket return &result, nil } -func (r MySQLLayananRepository) paketLayanan(ctx context.Context, id string) ([]PaketLayanan, error) { - rows, err := r.db.QueryContext(ctx, ` +type paketBasePricing struct { + layananByOperation map[string]float64 + itemByOperation map[string]float64 +} + +func newPaketBasePricing() *paketBasePricing { + return &paketBasePricing{ + layananByOperation: make(map[string]float64), + itemByOperation: make(map[string]float64), + } +} + +func (r MySQLLayananRepository) activePaketTariffTable(ctx context.Context) (string, error) { + var periodID int64 + err := r.db.QueryRowContext(ctx, ` + SELECT PeriodeTarifID + FROM periode_tarif + WHERE DATE(TanggalBerlaku) <= CURRENT_DATE() + AND DATE(TanggalBerakhir) >= CURRENT_DATE() + AND NA = 'N' + ORDER BY PeriodeTarifID DESC + LIMIT 1`).Scan(&periodID) + if err == sql.ErrNoRows { + return "", nil + } + if err != nil { + return "", err + } + + tableName := "harga_layanan_" + strconv.FormatInt(periodID, 10) + exists, err := r.tableExists(ctx, tableName) + if err != nil || !exists { + return "", err + } + return tableName, nil +} + +func (r MySQLLayananRepository) paketLayanan( + ctx context.Context, + id string, + classID string, + tariffTable string, + pricing *paketBasePricing, +) ([]PaketLayanan, error) { + basePriceSelect := "COALESCE(ml.DefaultHarga, 0)" + args := make([]any, 0, 3) + if tariffTable != "" { + basePriceSelect = fmt.Sprintf(`COALESCE(( + SELECT tarif.Harga + FROM %s tarif + INNER JOIN layanandepartemen ld + ON ld.LayananDepartemenID = tarif.LayananDepartemenID + AND ld.NA = 'N' + WHERE ld.LayananID = pl.LayananID + AND tarif.KelasID IN (?, 'Default') + AND tarif.NA = 'N' + ORDER BY CASE + WHEN tarif.KelasID = ? AND ld.DepartemenID <=> pl.DepartemenID THEN 1 + WHEN tarif.KelasID = ? THEN 2 + WHEN tarif.KelasID = 'Default' AND ld.DepartemenID <=> pl.DepartemenID THEN 3 + ELSE 4 + END, tarif.HargaLayananID + LIMIT 1 + ), ml.DefaultHarga, 0)`, tariffTable) + args = append(args, classID, classID, classID) + } + args = append(args, id) + query := fmt.Sprintf(` 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) + CAST(pl.ShareDokter AS CHAR), CAST(pl.Subtotal AS CHAR), + %s, CAST(pl.HeaderLayanan 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) + ORDER BY pl.PaketLayananID`, basePriceSelect) + rows, err := r.db.QueryContext(ctx, query, args...) if err != nil { return nil, err } @@ -274,25 +351,31 @@ func (r MySQLLayananRepository) paketLayanan(ctx context.Context, id string) ([] var item PaketLayanan var namaLayanan, namaDepartemen sql.NullString var harga, shareRS, shareDokter, subtotal string + var basePrice float64 + var operationHeader sql.NullString if err := rows.Scan( &item.PaketLayananID, &item.LayananID, &namaLayanan, &namaDepartemen, &item.Jumlah, &item.JumlahHari, &harga, &item.Diskon, &item.TipeDiskon, - &shareRS, &shareDokter, &subtotal, + &shareRS, &shareDokter, &subtotal, &basePrice, &operationHeader, ); err != nil { return nil, err } item.NamaLayanan = nullStringPtr(namaLayanan) item.NamaDepartemen = nullStringPtr(namaDepartemen) item.Harga = phpNumberFormat(parseDecimal(harga), 0) + item.HargaDasar = phpNumberFormat(basePrice, 0) item.ShareRS = phpNumberFormat(parseDecimal(shareRS), 2) item.ShareDokter = phpNumberFormat(parseDecimal(shareDokter), 0) item.Subtotal = phpNumberFormat(parseDecimal(subtotal), 0) + if operationHeader.Valid { + pricing.layananByOperation[operationHeader.String] += basePrice * parseDecimal(item.Jumlah) + } result = append(result, item) } return result, rows.Err() } -func (r MySQLLayananRepository) paketOperasi(ctx context.Context, id string) ([]PaketOperasi, error) { +func (r MySQLLayananRepository) paketOperasi(ctx context.Context, id string, pricing *paketBasePricing) ([]PaketOperasi, error) { rows, err := r.db.QueryContext(ctx, ` SELECT po.PaketOperasiHeaderID, mt.Nama, go.Nama, ja.Nama, ko.Nama, po.EstimasiWaktu, po.Keterangan @@ -321,19 +404,35 @@ func (r MySQLLayananRepository) paketOperasi(ctx context.Context, id string) ([] item.NamaGolongan = nullStringPtr(golongan) item.NamaJenisAnestesi = nullStringPtr(anestesi) item.NamaKasusOperasi = nullStringPtr(kasus) + headerID := strconv.FormatInt(item.PaketOperasiHeaderID, 10) + item.HargaDasar = phpNumberFormat( + pricing.layananByOperation[headerID]+pricing.itemByOperation[headerID], + 2, + ) result = append(result, item) } return result, rows.Err() } -func (r MySQLLayananRepository) paketItem(ctx context.Context, id string) ([]PaketItem, error) { +func (r MySQLLayananRepository) paketItem(ctx context.Context, id string, pricing *paketBasePricing) ([]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) + AND ks.SatuanAsal = pi.SatuanID AND ks.NA = 'N' LIMIT 1), 1), + COALESCE(mi.HET, 'N'), COALESCE(mi.DefaultHarga, 0), + COALESCE(mi.CostDasar, 0), COALESCE(mi.KategoriItemID, 0), + (SELECT mo.Margin FROM margin_obat mo + WHERE mo.Dari <= mi.CostDasar AND mo.Sampai >= mi.CostDasar + AND mo.JenisReg = 'RJ' AND mo.NA = 'N' LIMIT 1), + (SELECT ss.Value_ FROM sys_setting ss + WHERE ss.Setting = 'Margin_RJ' AND ss.NA = 'N' LIMIT 1), + (SELECT ma.Margin FROM margin_alkes ma + WHERE ma.Dari <= mi.CostDasar AND ma.Sampai >= mi.CostDasar + AND ma.NA = 'N' LIMIT 1), + CAST(pi.HeaderID AS CHAR) 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' @@ -350,10 +449,17 @@ func (r MySQLLayananRepository) paketItem(ctx context.Context, id string) ([]Pak var namaItem, namaDepartemen, namaSatuan sql.NullString var harga, satuanID, satuanDasarID string var konversi float64 + var het string + var defaultPrice, baseCost float64 + var categoryID int + var medicineMargin, settingMargin, medicalSupplyMargin sql.NullFloat64 + var operationHeader sql.NullString if err := rows.Scan( &item.PaketItemID, &item.ItemID, &namaItem, &namaDepartemen, &item.Jumlah, &namaSatuan, &harga, &item.Diskon, &item.TipeDiskon, &satuanID, &satuanDasarID, &konversi, + &het, &defaultPrice, &baseCost, &categoryID, + &medicineMargin, &settingMargin, &medicalSupplyMargin, &operationHeader, ); err != nil { return nil, err } @@ -370,7 +476,15 @@ func (r MySQLLayananRepository) paketItem(ctx context.Context, id string) ([]Pak konversi = 1 } item.Harga = phpNumberFormat(hargaValue, 2) + basePrice := paketItemBasePrice( + het, defaultPrice, baseCost, categoryID, + medicineMargin, settingMargin, medicalSupplyMargin, + ) + item.HargaDasar = phpNumberFormat(basePrice, 2) item.SubTotal = phpNumberFormat((hargaValue-nDisc)*parseDecimal(item.Jumlah)*konversi, 2) + if operationHeader.Valid { + pricing.itemByOperation[operationHeader.String] += basePrice * parseDecimal(item.Jumlah) * konversi + } result = append(result, item) } return result, rows.Err() @@ -379,7 +493,8 @@ func (r MySQLLayananRepository) paketItem(ctx context.Context, id string) ([]Pak 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) + CAST(pt.Harga AS CHAR), pt.TipeDiskon, CAST(pt.Diskon AS CHAR), + COALESCE(k.HargaTT, 0) 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' @@ -394,9 +509,10 @@ func (r MySQLLayananRepository) paketKamar(ctx context.Context, id string) ([]Pa var item PaketKamar var namaDepartemen, namaKelas sql.NullString var harga, diskon string + var basePrice float64 if err := rows.Scan( &item.PaketTTID, &namaDepartemen, &namaKelas, &item.Jumlah, - &harga, &item.TipeDiskon, &diskon, + &harga, &item.TipeDiskon, &diskon, &basePrice, ); err != nil { return nil, err } @@ -409,12 +525,40 @@ func (r MySQLLayananRepository) paketKamar(ctx context.Context, id string) ([]Pa item.Diskon = hargaValue * (diskonValue / 100) } item.Harga = phpNumberFormat(hargaValue, 2) + item.HargaDasar = phpNumberFormat(basePrice, 2) item.SubTotal = phpNumberFormat((hargaValue-item.Diskon)*parseDecimal(item.Jumlah), 0) result = append(result, item) } return result, rows.Err() } +func paketItemBasePrice( + het string, + defaultPrice float64, + baseCost float64, + categoryID int, + medicineMargin sql.NullFloat64, + settingMargin sql.NullFloat64, + medicalSupplyMargin sql.NullFloat64, +) float64 { + if het == "Y" { + return defaultPrice + } + + switch categoryID { + case 1: + margin := settingMargin.Float64 + if medicineMargin.Valid { + margin = medicineMargin.Float64 + } + return math.Round(baseCost + baseCost*margin/100) + case 2: + return math.Round(baseCost + baseCost*medicalSupplyMargin.Float64/100) + default: + return math.Round(defaultPrice) + } +} + func parseDecimal(value string) float64 { parsed, _ := strconv.ParseFloat(value, 64) return parsed diff --git a/internal/repository/paket_test.go b/internal/repository/paket_test.go index 58499bc..6cfce8f 100644 --- a/internal/repository/paket_test.go +++ b/internal/repository/paket_test.go @@ -1,6 +1,9 @@ package repository -import "testing" +import ( + "database/sql" + "testing" +) func TestPaketListWhereParity(t *testing.T) { where, args := paketListWhere(PaketListParams{ @@ -43,3 +46,51 @@ func TestLaravelDateJSONParity(t *testing.T) { t.Fatalf("zero date=%q", actual) } } + +func TestPaketItemBasePriceFallbackParity(t *testing.T) { + tests := []struct { + name string + het string + defaultPrice float64 + baseCost float64 + categoryID int + medicine sql.NullFloat64 + setting sql.NullFloat64 + medicalSupply sql.NullFloat64 + expected float64 + }{ + { + name: "HET uses default price", het: "Y", defaultPrice: 1234.56, + expected: 1234.56, + }, + { + name: "medicine uses tier margin", baseCost: 1000, categoryID: 1, + medicine: sql.NullFloat64{Float64: 12.5, Valid: true}, + setting: sql.NullFloat64{Float64: 20, Valid: true}, expected: 1125, + }, + { + name: "medicine falls back to system setting", baseCost: 1000, categoryID: 1, + setting: sql.NullFloat64{Float64: 20, Valid: true}, expected: 1200, + }, + { + name: "medical supply uses its margin", baseCost: 1000, categoryID: 2, + medicalSupply: sql.NullFloat64{Float64: 15, Valid: true}, expected: 1150, + }, + { + name: "other category uses rounded default", defaultPrice: 1234.56, + categoryID: 3, expected: 1235, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + actual := paketItemBasePrice( + test.het, test.defaultPrice, test.baseCost, test.categoryID, + test.medicine, test.setting, test.medicalSupply, + ) + if actual != test.expected { + t.Fatalf("base price=%v want=%v", actual, test.expected) + } + }) + } +}