feat: add package base price fallbacks

This commit is contained in:
2026-08-22 13:29:57 +07:00
parent 412e926a65
commit 30ddf25012
3 changed files with 196 additions and 27 deletions

View File

@@ -103,7 +103,7 @@ func TestGetPaketResponseAndNotFoundParity(t *testing.T) {
PeriodeTarifID: 42, Keterangan: &keteranganPeriode, PeriodeTarifID: 42, Keterangan: &keteranganPeriode,
TanggalBerlaku: "2026-01-01", TanggalBerakhir: "2026-12-31", TanggalBerlaku: "2026-01-01", TanggalBerakhir: "2026-12-31",
}, },
Layanan: []repository.PaketLayanan{{HargaDasar: "100,000"}}, Layanan: []repository.PaketLayanan{{HargaDasar: "100,000", HargaDasarSource: "TARIF_PERIODE"}},
Operasi: []repository.PaketOperasi{{HargaDasar: "250,000.00"}}, Operasi: []repository.PaketOperasi{{HargaDasar: "250,000.00"}},
Item: []repository.PaketItem{{HargaDasar: "50,000.00"}}, Item: []repository.PaketItem{{HargaDasar: "50,000.00"}},
Kamar: []repository.PaketKamar{{HargaDasar: "200,000.00"}}, Kamar: []repository.PaketKamar{{HargaDasar: "200,000.00"}},
@@ -120,6 +120,7 @@ func TestGetPaketResponseAndNotFoundParity(t *testing.T) {
for _, expected := range []string{ for _, expected := range []string{
`"PeriodeTarif":{"PeriodeTarifID":42,"Keterangan":"Tarif Dasar 2026","TanggalBerlaku":"2026-01-01","TanggalBerakhir":"2026-12-31"}`, `"PeriodeTarif":{"PeriodeTarifID":42,"Keterangan":"Tarif Dasar 2026","TanggalBerlaku":"2026-01-01","TanggalBerakhir":"2026-12-31"}`,
`"HargaDasar":"100,000"`, `"HargaDasar":"100,000"`,
`"HargaDasarSource":"TARIF_PERIODE"`,
`"HargaDasar":"250,000.00"`, `"HargaDasar":"250,000.00"`,
`"HargaDasar":"50,000.00"`, `"HargaDasar":"50,000.00"`,
`"HargaDasar":"200,000.00"`, `"HargaDasar":"200,000.00"`,

View File

@@ -84,19 +84,20 @@ type PaketPeriodeTarif struct {
} }
type PaketLayanan struct { type PaketLayanan struct {
PaketLayananID int64 `json:"PaketLayananID"` PaketLayananID int64 `json:"PaketLayananID"`
LayananID string `json:"LayananID"` LayananID string `json:"LayananID"`
NamaLayanan *string `json:"NamaLayanan"` NamaLayanan *string `json:"NamaLayanan"`
NamaDepartemen *string `json:"NamaDepartemen"` NamaDepartemen *string `json:"NamaDepartemen"`
Jumlah string `json:"Jumlah"` Jumlah string `json:"Jumlah"`
JumlahHari int `json:"JumlahHari"` JumlahHari int `json:"JumlahHari"`
Harga string `json:"Harga"` Harga string `json:"Harga"`
HargaDasar string `json:"HargaDasar"` HargaDasar string `json:"HargaDasar"`
Diskon string `json:"Diskon"` HargaDasarSource string `json:"HargaDasarSource"`
TipeDiskon string `json:"TipeDiskon"` Diskon string `json:"Diskon"`
ShareRS string `json:"ShareRS"` TipeDiskon string `json:"TipeDiskon"`
ShareDokter string `json:"ShareDokter"` ShareRS string `json:"ShareRS"`
Subtotal string `json:"Subtotal"` ShareDokter string `json:"ShareDokter"`
Subtotal string `json:"Subtotal"`
} }
type PaketOperasi struct { type PaketOperasi struct {
@@ -257,7 +258,7 @@ func (r MySQLLayananRepository) GetPaket(ctx context.Context, id string) (*Paket
} }
result.PeriodeTarif = periodeTarif result.PeriodeTarif = periodeTarif
pricing := newPaketBasePricing() pricing := newPaketBasePricing()
if result.Layanan, err = r.paketLayanan(ctx, id, result.KelasID, tariffTable, pricing); err != nil { if result.Layanan, err = r.paketLayanan(ctx, id, result.KelasID, periodeTarif, tariffTable, pricing); err != nil {
return nil, err return nil, err
} }
if result.Item, err = r.paketItem(ctx, id, pricing); err != nil { if result.Item, err = r.paketItem(ctx, id, pricing); err != nil {
@@ -321,13 +322,14 @@ func (r MySQLLayananRepository) paketLayanan(
ctx context.Context, ctx context.Context,
id string, id string,
classID string, classID string,
periodeTarif *PaketPeriodeTarif,
tariffTable string, tariffTable string,
pricing *paketBasePricing, pricing *paketBasePricing,
) ([]PaketLayanan, error) { ) ([]PaketLayanan, error) {
basePriceSelect := "COALESCE(ml.DefaultHarga, 0)" tariffPriceSelect := "NULL"
args := make([]any, 0, 3) args := make([]any, 0, 3)
if tariffTable != "" { if tariffTable != "" {
basePriceSelect = fmt.Sprintf(`COALESCE(( tariffPriceSelect = fmt.Sprintf(`(
SELECT tarif.Harga SELECT tarif.Harga
FROM %s tarif FROM %s tarif
INNER JOIN layanandepartemen ld INNER JOIN layanandepartemen ld
@@ -336,6 +338,7 @@ func (r MySQLLayananRepository) paketLayanan(
WHERE ld.LayananID = pl.LayananID WHERE ld.LayananID = pl.LayananID
AND tarif.KelasID IN (?, 'Default') AND tarif.KelasID IN (?, 'Default')
AND tarif.NA = 'N' AND tarif.NA = 'N'
AND tarif.Harga > 0
ORDER BY CASE ORDER BY CASE
WHEN tarif.KelasID = ? AND ld.DepartemenID <=> pl.DepartemenID THEN 1 WHEN tarif.KelasID = ? AND ld.DepartemenID <=> pl.DepartemenID THEN 1
WHEN tarif.KelasID = ? THEN 2 WHEN tarif.KelasID = ? THEN 2
@@ -343,7 +346,7 @@ func (r MySQLLayananRepository) paketLayanan(
ELSE 4 ELSE 4
END, tarif.HargaLayananID END, tarif.HargaLayananID
LIMIT 1 LIMIT 1
), ml.DefaultHarga, 0)`, tariffTable) )`, tariffTable)
args = append(args, classID, classID, classID) args = append(args, classID, classID, classID)
} }
args = append(args, id) args = append(args, id)
@@ -352,44 +355,150 @@ func (r MySQLLayananRepository) paketLayanan(
CAST(pl.Jumlah AS CHAR), pl.JumlahHari, CAST(pl.Harga AS CHAR), 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.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) %s, COALESCE(ml.DefaultHarga, 0), COALESCE(ml.LayananKode, ''),
CAST(pl.HeaderLayanan AS CHAR)
FROM paket_layanan pl FROM paket_layanan pl
LEFT JOIN masterlayanan ml ON ml.LayananID = pl.LayananID LEFT JOIN masterlayanan ml ON ml.LayananID = pl.LayananID
LEFT JOIN departemen d ON d.DepartemenID = pl.DepartemenID AND d.NA = 'N' LEFT JOIN departemen d ON d.DepartemenID = pl.DepartemenID AND d.NA = 'N'
WHERE pl.PaketID = ? AND pl.NA = 'N' WHERE pl.PaketID = ? AND pl.NA = 'N'
ORDER BY pl.PaketLayananID`, basePriceSelect) ORDER BY pl.PaketLayananID`, tariffPriceSelect)
rows, err := r.db.QueryContext(ctx, query, args...) rows, err := r.db.QueryContext(ctx, query, args...)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
result := make([]PaketLayanan, 0) result := make([]PaketLayanan, 0)
type servicePricing struct {
tariffPrice sql.NullFloat64
defaultPrice float64
serviceCode string
operationHeader sql.NullString
}
pricingRows := make([]servicePricing, 0)
needsAdministrationPrice := false
for rows.Next() { for rows.Next() {
var item PaketLayanan var item PaketLayanan
var namaLayanan, namaDepartemen sql.NullString var namaLayanan, namaDepartemen sql.NullString
var harga, shareRS, shareDokter, subtotal string var harga, shareRS, shareDokter, subtotal string
var basePrice float64 var servicePrice servicePricing
var operationHeader sql.NullString
if err := rows.Scan( if err := rows.Scan(
&item.PaketLayananID, &item.LayananID, &namaLayanan, &namaDepartemen, &item.PaketLayananID, &item.LayananID, &namaLayanan, &namaDepartemen,
&item.Jumlah, &item.JumlahHari, &harga, &item.Diskon, &item.TipeDiskon, &item.Jumlah, &item.JumlahHari, &harga, &item.Diskon, &item.TipeDiskon,
&shareRS, &shareDokter, &subtotal, &basePrice, &operationHeader, &shareRS, &shareDokter, &subtotal, &servicePrice.tariffPrice,
&servicePrice.defaultPrice, &servicePrice.serviceCode, &servicePrice.operationHeader,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
item.NamaLayanan = nullStringPtr(namaLayanan) item.NamaLayanan = nullStringPtr(namaLayanan)
item.NamaDepartemen = nullStringPtr(namaDepartemen) item.NamaDepartemen = nullStringPtr(namaDepartemen)
item.Harga = phpNumberFormat(parseDecimal(harga), 0) item.Harga = phpNumberFormat(parseDecimal(harga), 0)
item.HargaDasar = phpNumberFormat(basePrice, 0)
item.ShareRS = phpNumberFormat(parseDecimal(shareRS), 2) item.ShareRS = phpNumberFormat(parseDecimal(shareRS), 2)
item.ShareDokter = phpNumberFormat(parseDecimal(shareDokter), 0) item.ShareDokter = phpNumberFormat(parseDecimal(shareDokter), 0)
item.Subtotal = phpNumberFormat(parseDecimal(subtotal), 0) item.Subtotal = phpNumberFormat(parseDecimal(subtotal), 0)
if operationHeader.Valid { if servicePrice.tariffPrice.Float64 <= 0 && servicePrice.defaultPrice <= 0 && isAdministrationService(servicePrice.serviceCode) {
pricing.layananByOperation[operationHeader.String] += basePrice * parseDecimal(item.Jumlah) needsAdministrationPrice = true
} }
result = append(result, item) result = append(result, item)
pricingRows = append(pricingRows, servicePrice)
} }
return result, rows.Err() if err := rows.Err(); err != nil {
return nil, err
}
administrationPrice := 0.0
administrationSource := "NOT_AVAILABLE"
if needsAdministrationPrice {
var err error
administrationPrice, administrationSource, err = r.paketAdministrationBasePrice(ctx, periodeTarif)
if err != nil {
return nil, err
}
}
for index := range result {
servicePrice := pricingRows[index]
basePrice, source := paketServiceBasePrice(
servicePrice.tariffPrice,
servicePrice.defaultPrice,
servicePrice.serviceCode,
administrationPrice,
administrationSource,
)
result[index].HargaDasar = phpNumberFormat(basePrice, 0)
result[index].HargaDasarSource = source
if servicePrice.operationHeader.Valid {
pricing.layananByOperation[servicePrice.operationHeader.String] += basePrice * parseDecimal(result[index].Jumlah)
}
}
return result, nil
}
func paketServiceBasePrice(
tariffPrice sql.NullFloat64,
defaultPrice float64,
serviceCode string,
administrationPrice float64,
administrationSource string,
) (float64, string) {
if tariffPrice.Valid && tariffPrice.Float64 > 0 {
return tariffPrice.Float64, "TARIF_PERIODE"
}
if defaultPrice > 0 {
return defaultPrice, "MASTER_DEFAULT"
}
if isAdministrationService(serviceCode) && administrationPrice > 0 {
return administrationPrice, administrationSource
}
return 0, "NOT_AVAILABLE"
}
func isAdministrationService(serviceCode string) bool {
switch strings.ToUpper(strings.TrimSpace(serviceCode)) {
case "AN001", "AD001":
return true
default:
return false
}
}
func (r MySQLLayananRepository) paketAdministrationBasePrice(
ctx context.Context,
periodeTarif *PaketPeriodeTarif,
) (float64, string, error) {
if periodeTarif != nil {
exists, err := r.tableExists(ctx, "adm_periode")
if err != nil {
return 0, "", err
}
if exists {
var price sql.NullFloat64
err = r.db.QueryRowContext(ctx, `
SELECT NilaiAdministrasi
FROM adm_periode
WHERE JenisRegID = 0 AND PeriodeTarifID = ?
ORDER BY AdmPeriodeID DESC
LIMIT 1`, periodeTarif.PeriodeTarifID).Scan(&price)
if err != nil && err != sql.ErrNoRows {
return 0, "", err
}
if price.Valid && price.Float64 > 0 {
return price.Float64, "ADM_PERIODE", nil
}
}
}
var price sql.NullFloat64
err := r.db.QueryRowContext(ctx, `
SELECT NilaiAdministrasi
FROM jenisreg
WHERE JenisRegID = 0
LIMIT 1`).Scan(&price)
if err != nil && err != sql.ErrNoRows {
return 0, "", err
}
if price.Valid && price.Float64 > 0 {
return price.Float64, "BIAYA_ADMIN", nil
}
return 0, "NOT_AVAILABLE", nil
} }
func (r MySQLLayananRepository) paketOperasi(ctx context.Context, id string, pricing *paketBasePricing) ([]PaketOperasi, error) { func (r MySQLLayananRepository) paketOperasi(ctx context.Context, id string, pricing *paketBasePricing) ([]PaketOperasi, error) {

View File

@@ -94,3 +94,62 @@ func TestPaketItemBasePriceFallbackParity(t *testing.T) {
}) })
} }
} }
func TestPaketServiceBasePriceFallbackParity(t *testing.T) {
tests := []struct {
name string
tariffPrice sql.NullFloat64
defaultPrice float64
serviceCode string
administrationPrice float64
administrationSource string
expectedPrice float64
expectedSource string
}{
{
name: "positive period tariff has highest priority",
tariffPrice: sql.NullFloat64{Float64: 150000, Valid: true},
defaultPrice: 100000, serviceCode: "LAB001",
expectedPrice: 150000, expectedSource: "TARIF_PERIODE",
},
{
name: "zero period tariff does not override master default",
tariffPrice: sql.NullFloat64{Float64: 0, Valid: true},
defaultPrice: 100000, serviceCode: "LAB001",
expectedPrice: 100000, expectedSource: "MASTER_DEFAULT",
},
{
name: "AN001 uses administration period fallback",
serviceCode: "an001", administrationPrice: 75000,
administrationSource: "ADM_PERIODE",
expectedPrice: 75000, expectedSource: "ADM_PERIODE",
},
{
name: "AD001 uses registration master fallback",
serviceCode: " AD001 ", administrationPrice: 50000,
administrationSource: "BIAYA_ADMIN",
expectedPrice: 50000, expectedSource: "BIAYA_ADMIN",
},
{
name: "non administration service does not use administration fallback",
serviceCode: "LAB001", administrationPrice: 50000,
administrationSource: "BIAYA_ADMIN",
expectedPrice: 0, expectedSource: "NOT_AVAILABLE",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
price, source := paketServiceBasePrice(
test.tariffPrice,
test.defaultPrice,
test.serviceCode,
test.administrationPrice,
test.administrationSource,
)
if price != test.expectedPrice || source != test.expectedSource {
t.Fatalf("price=%v source=%q want price=%v source=%q", price, source, test.expectedPrice, test.expectedSource)
}
})
}
}