feat: add cooperation tariff APIs

This commit is contained in:
2026-08-22 10:14:12 +07:00
parent b9eb934364
commit d183e321e8
7 changed files with 798 additions and 1 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

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

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