Compare commits

..

2 Commits

Author SHA1 Message Date
d183e321e8 feat: add cooperation tariff APIs 2026-08-22 10:14:12 +07:00
b9eb934364 feat: add package base prices 2026-08-21 10:21:47 +07:00
10 changed files with 1023 additions and 18 deletions

View File

@@ -7,6 +7,12 @@ GET /api/cpone/paket
GET /api/cpone/paket/{id}
Authorization: Bearer <your-secure-token>
GET /api/cpone/tarif-kerjasama?search=&perusahaan_id=&tarif_id=&page=1&per_page=15
Authorization: Bearer <your-secure-token>
GET /api/cpone/tarif-kerjasama/{id}?group_tarif_id=&active=N&effective_date=2026-08-22
Authorization: Bearer <your-secure-token>
GET /api/cpone/layanan-mapping-harga?periodeTarifID=10&layananID=6413,6414,1309
Authorization: Bearer <your-secure-token>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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