commit 676d930561f931ad44b57d8fff75eea9db54f38f Author: Tubagus Fajri Date: Tue Aug 18 13:41:38 2026 +0700 Initial CPONE middleware implementation diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3105bf6 --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +APP_HOST=0.0.0.0 +APP_PORT=8080 + +DB_HOST=127.0.0.1 +DB_PORT=3306 +DB_DATABASE=primaya +DB_USERNAME=root +DB_PASSWORD= + +CPONE_BEARER_TOKEN=replace-with-a-secure-token diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..422c591 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# Local configuration and secrets +.env +.env.* +!.env.example + +# Build outputs +/cpone-middleware +/dist/ +/bin/ + +# Test and coverage outputs +*.test +coverage.out + +# Editor and OS files +.idea/ +.vscode/ +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..837d67f --- /dev/null +++ b/README.md @@ -0,0 +1,39 @@ +# CPONE Middleware Go + +Service Go untuk endpoint: + +```text +GET /api/cpone/layanan-mapping-harga?periodeTarifID=10&layananID=6413,6414,1309 +Authorization: Bearer +``` + +Format response dibuat mengikuti controller PHP `LayananDepartemenController::mappingHarga`. + +## Config + +Copy `.env.example` menjadi `.env`, lalu isi koneksi MySQL: + +```text +APP_HOST=0.0.0.0 +APP_PORT=8080 +DB_HOST=127.0.0.1 +DB_PORT=3306 +DB_DATABASE=primaya +DB_USERNAME=root +DB_PASSWORD= +CPONE_BEARER_TOKEN=replace-with-a-secure-token +``` + +## Run + +```bash +cd cpone-middleware +go mod tidy +go run ./cmd/server +``` + +## SQLC + +File `sqlc.yaml`, `sqlc/schema.sql`, dan `sqlc/query.sql` sudah disiapkan. + +Catatan penting: sqlc tidak bisa menerima nama tabel dinamis seperti `harga_layanan_{periodeTarifID}`. Implementasi runtime saat ini tetap memakai `database/sql` untuk menyusun nama tabel setelah `periodeTarifID` divalidasi angka, sama seperti PHP memakai `HargaLayanan::makeTableName()`. Kalau semua periode ingin digenerate oleh sqlc, buat query konkret per tabel periode, misalnya `harga_layanan_10`, `harga_layanan_11`, dan seterusnya. diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..6ab0252 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,76 @@ +package main + +import ( + "context" + "database/sql" + "errors" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + _ "github.com/go-sql-driver/mysql" + + "primaya-api/cpone-middleware/internal/config" + apphttp "primaya-api/cpone-middleware/internal/http" + "primaya-api/cpone-middleware/internal/repository" +) + +func main() { + cfg, err := config.Load(".env") + if err != nil { + log.Fatalf("load config: %v", err) + } + + db, err := sql.Open("mysql", cfg.MySQLDSN()) + if err != nil { + log.Fatalf("open mysql: %v", err) + } + defer db.Close() + + db.SetMaxOpenConns(cfg.DBMaxOpenConns) + db.SetMaxIdleConns(cfg.DBMaxIdleConns) + db.SetConnMaxLifetime(cfg.DBConnMaxLifetime) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := db.PingContext(ctx); err != nil { + log.Fatalf("ping mysql: %v", err) + } + + repo := repository.NewMySQLLayananRepository(db) + handler := apphttp.NewHandler(repo) + + mux := http.NewServeMux() + mux.Handle("GET /api/cpone/layanan-mapping-harga", apphttp.BearerAuth(cfg.BearerToken, http.HandlerFunc(handler.MappingHarga))) + mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"success":true,"message":"ok","data":null}`)) + }) + + server := &http.Server{ + Addr: cfg.Addr(), + Handler: mux, + ReadHeaderTimeout: 10 * time.Second, + } + + go func() { + log.Printf("cpone middleware listening on %s", cfg.Addr()) + if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Fatalf("listen: %v", err) + } + }() + + stop := make(chan os.Signal, 1) + signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM) + <-stop + + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer shutdownCancel() + if err := server.Shutdown(shutdownCtx); err != nil { + log.Fatalf("shutdown: %v", err) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..cb1f10b --- /dev/null +++ b/go.mod @@ -0,0 +1,7 @@ +module primaya-api/cpone-middleware + +go 1.24 + +require github.com/go-sql-driver/mysql v1.9.3 + +require filippo.io/edwards25519 v1.1.0 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..4bcdcfa --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= +github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..d66b61d --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,131 @@ +package config + +import ( + "bufio" + "fmt" + "net/url" + "os" + "strconv" + "strings" + "time" +) + +type Config struct { + AppHost string + AppPort string + DBHost string + DBPort string + DBDatabase string + DBUsername string + DBPassword string + BearerToken string + DBMaxOpenConns int + DBMaxIdleConns int + DBConnMaxLifetime time.Duration +} + +func Load(path string) (Config, error) { + if err := loadDotEnv(path); err != nil { + return Config{}, err + } + + cfg := Config{ + AppHost: env("APP_HOST", "0.0.0.0"), + AppPort: env("APP_PORT", "8080"), + DBHost: env("DB_HOST", "127.0.0.1"), + DBPort: env("DB_PORT", "3306"), + DBDatabase: env("DB_DATABASE", ""), + DBUsername: env("DB_USERNAME", "root"), + DBPassword: env("DB_PASSWORD", ""), + BearerToken: env("CPONE_BEARER_TOKEN", ""), + DBMaxOpenConns: envInt("DB_MAX_OPEN_CONNS", 25), + DBMaxIdleConns: envInt("DB_MAX_IDLE_CONNS", 25), + DBConnMaxLifetime: time.Duration(envInt("DB_CONN_MAX_LIFETIME_MINUTES", 5)) * time.Minute, + } + + if cfg.DBDatabase == "" { + return Config{}, fmt.Errorf("DB_DATABASE wajib diisi") + } + if cfg.BearerToken == "" { + return Config{}, fmt.Errorf("CPONE_BEARER_TOKEN wajib diisi") + } + + return cfg, nil +} + +func (c Config) Addr() string { + return c.AppHost + ":" + c.AppPort +} + +func (c Config) MySQLDSN() string { + values := url.Values{} + values.Set("charset", "utf8mb4") + values.Set("parseTime", "true") + values.Set("loc", "Local") + + return fmt.Sprintf( + "%s:%s@tcp(%s:%s)/%s?%s", + c.DBUsername, + c.DBPassword, + c.DBHost, + c.DBPort, + c.DBDatabase, + values.Encode(), + ) +} + +func loadDotEnv(path string) error { + file, err := os.Open(path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + defer file.Close() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + key, value, ok := strings.Cut(line, "=") + if !ok { + continue + } + + key = strings.TrimSpace(key) + value = strings.TrimSpace(value) + value = strings.Trim(value, `"'`) + + if key != "" { + _ = os.Setenv(key, value) + } + } + + return scanner.Err() +} + +func env(key string, fallback string) string { + value := os.Getenv(key) + if value == "" { + return fallback + } + return value +} + +func envInt(key string, fallback int) int { + value := os.Getenv(key) + if value == "" { + return fallback + } + + parsed, err := strconv.Atoi(value) + if err != nil { + return fallback + } + + return parsed +} diff --git a/internal/db/db.go b/internal/db/db.go new file mode 100644 index 0000000..f43598b --- /dev/null +++ b/internal/db/db.go @@ -0,0 +1,31 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package db + +import ( + "context" + "database/sql" +) + +type DBTX interface { + ExecContext(context.Context, string, ...interface{}) (sql.Result, error) + PrepareContext(context.Context, string) (*sql.Stmt, error) + QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) + QueryRowContext(context.Context, string, ...interface{}) *sql.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx *sql.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/internal/db/models.go b/internal/db/models.go new file mode 100644 index 0000000..44a0890 --- /dev/null +++ b/internal/db/models.go @@ -0,0 +1,39 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package db + +import ( + "database/sql" +) + +type HargaLayanan10 struct { + Hargalayananid int64 `json:"hargalayananid"` + Layanandepartemenid sql.NullInt64 `json:"layanandepartemenid"` + Kelasid sql.NullString `json:"kelasid"` + Na sql.NullString `json:"na"` + Harga sql.NullString `json:"harga"` + Sharetipe sql.NullString `json:"sharetipe"` + Sharedokter sql.NullString `json:"sharedokter"` + Sharers sql.NullString `json:"sharers"` + Shareexternal sql.NullString `json:"shareexternal"` + Sharelain sql.NullString `json:"sharelain"` +} + +type Jenislayanan struct { + Jenislayananid int64 `json:"jenislayananid"` + Nama sql.NullString `json:"nama"` +} + +type Layanandeparteman struct { + Layanandepartemenid int64 `json:"layanandepartemenid"` + Layananid sql.NullString `json:"layananid"` + Na sql.NullString `json:"na"` +} + +type Masterlayanan struct { + Layananid string `json:"layananid"` + Nama sql.NullString `json:"nama"` + Jenislayananid sql.NullInt64 `json:"jenislayananid"` +} diff --git a/internal/db/query.sql.go b/internal/db/query.sql.go new file mode 100644 index 0000000..559cee3 --- /dev/null +++ b/internal/db/query.sql.go @@ -0,0 +1,135 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: query.sql + +package db + +import ( + "context" + "database/sql" + "strings" +) + +const mappingHargaPeriode10 = `-- name: MappingHargaPeriode10 :many +SELECT + tarif.HargaLayananID AS TarifLayananID, + layanan_dept.LayananID, + layanan.Nama AS NamaLayanan, + layanan.JenisLayananID, + jenis.Nama AS NamaJenisLayanan, + 'Default' AS Kelas, + 'Default' AS NamaKelas, + tarif.Harga, + 'Percentage' AS TipeDiskonRJ, + '0.00' AS DiskonRJ, + 'Percentage' AS TipeDiskonRI, + '0.00' AS DiskonRI, + tarif.ShareTipe AS TipeShare, + tarif.ShareDokter, + tarif.ShareRS, + tarif.ShareExternal AS ShareExt, + tarif.ShareLain, + '+Layanan' AS AsalTarif +FROM layanandepartemen AS layanan_dept +INNER JOIN harga_layanan_10 AS tarif + ON tarif.LayananDepartemenID = layanan_dept.LayananDepartemenID + AND tarif.KelasID = 'Default' + AND tarif.NA = 'N' +LEFT JOIN masterlayanan AS layanan + ON layanan.LayananID = layanan_dept.LayananID +LEFT JOIN jenislayanan AS jenis + ON jenis.JenisLayananID = layanan.JenisLayananID +WHERE layanan_dept.NA = 'N' + AND layanan_dept.LayananID IN (/*SLICE:layanan_ids*/?) + AND ( + ? IS NULL + OR layanan_dept.LayananID LIKE ? + OR layanan.Nama LIKE ? + ) +ORDER BY layanan.Nama, layanan.JenisLayananID +` + +type MappingHargaPeriode10Params struct { + LayananIds []sql.NullString `json:"layanan_ids"` + Search sql.NullString `json:"search"` +} + +type MappingHargaPeriode10Row struct { + Tariflayananid int64 `json:"tariflayananid"` + Layananid sql.NullString `json:"layananid"` + Namalayanan sql.NullString `json:"namalayanan"` + Jenislayananid sql.NullInt64 `json:"jenislayananid"` + Namajenislayanan sql.NullString `json:"namajenislayanan"` + Kelas string `json:"kelas"` + Namakelas string `json:"namakelas"` + Harga sql.NullString `json:"harga"` + Tipediskonrj string `json:"tipediskonrj"` + Diskonrj string `json:"diskonrj"` + Tipediskonri string `json:"tipediskonri"` + Diskonri string `json:"diskonri"` + Tipeshare sql.NullString `json:"tipeshare"` + Sharedokter sql.NullString `json:"sharedokter"` + Sharers sql.NullString `json:"sharers"` + Shareext sql.NullString `json:"shareext"` + Sharelain sql.NullString `json:"sharelain"` + Asaltarif string `json:"asaltarif"` +} + +// sqlc does not support dynamic table identifiers. This query is a generation +// template for one concrete period table; duplicate or generate per period as +// needed, then keep the HTTP response mapping unchanged. +func (q *Queries) MappingHargaPeriode10(ctx context.Context, arg MappingHargaPeriode10Params) ([]MappingHargaPeriode10Row, error) { + query := mappingHargaPeriode10 + var queryParams []interface{} + if len(arg.LayananIds) > 0 { + for _, v := range arg.LayananIds { + queryParams = append(queryParams, v) + } + query = strings.Replace(query, "/*SLICE:layanan_ids*/?", strings.Repeat(",?", len(arg.LayananIds))[1:], 1) + } else { + query = strings.Replace(query, "/*SLICE:layanan_ids*/?", "NULL", 1) + } + queryParams = append(queryParams, arg.Search) + queryParams = append(queryParams, arg.Search) + queryParams = append(queryParams, arg.Search) + rows, err := q.db.QueryContext(ctx, query, queryParams...) + if err != nil { + return nil, err + } + defer rows.Close() + var items []MappingHargaPeriode10Row + for rows.Next() { + var i MappingHargaPeriode10Row + if err := rows.Scan( + &i.Tariflayananid, + &i.Layananid, + &i.Namalayanan, + &i.Jenislayananid, + &i.Namajenislayanan, + &i.Kelas, + &i.Namakelas, + &i.Harga, + &i.Tipediskonrj, + &i.Diskonrj, + &i.Tipediskonri, + &i.Diskonri, + &i.Tipeshare, + &i.Sharedokter, + &i.Sharers, + &i.Shareext, + &i.Sharelain, + &i.Asaltarif, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/internal/http/handler.go b/internal/http/handler.go new file mode 100644 index 0000000..930fd02 --- /dev/null +++ b/internal/http/handler.go @@ -0,0 +1,134 @@ +package http + +import ( + "context" + "errors" + "net/http" + "regexp" + "strings" + + "primaya-api/cpone-middleware/internal/repository" +) + +var digitsOnly = regexp.MustCompile(`^\d+$`) + +type LayananRepository interface { + MappingHarga(ctx context.Context, params repository.MappingHargaParams) ([]repository.TarifLayanan, error) +} + +type Handler struct { + repo LayananRepository +} + +func NewHandler(repo LayananRepository) Handler { + return Handler{repo: repo} +} + +func (h Handler) MappingHarga(w http.ResponseWriter, r *http.Request) { + layananIDs := parseLayananIDs(r) + if len(layananIDs) == 0 { + WriteJSON(w, http.StatusUnprocessableEntity, Response{ + Success: false, + Message: "Parameter layananID wajib diisi dan dapat berupa satu atau beberapa LayananID", + Data: nil, + }) + return + } + + periodeTarifID, ok := firstQueryValue(r, "periodeTarifID", "periode_tarif_id") + if !ok || strings.TrimSpace(periodeTarifID) == "" { + WriteJSON(w, http.StatusUnprocessableEntity, Response{ + Success: false, + Message: "Parameter periodeTarifID wajib diisi", + Data: nil, + }) + return + } + + periodeTarifID = strings.TrimSpace(periodeTarifID) + if !digitsOnly.MatchString(periodeTarifID) { + WriteJSON(w, http.StatusUnprocessableEntity, Response{ + Success: false, + Message: "Parameter periodeTarifID harus berupa angka", + Data: nil, + }) + return + } + + rows, err := h.repo.MappingHarga(r.Context(), repository.MappingHargaParams{ + PeriodeTarifID: periodeTarifID, + LayananIDs: layananIDs, + Search: strings.TrimSpace(r.URL.Query().Get("search")), + }) + if err != nil { + if errors.Is(err, repository.ErrTarifTableNotFound) { + WriteJSON(w, http.StatusNotFound, Response{ + Success: false, + Message: "Tabel tarif dasar tidak ditemukan: harga_layanan_" + periodeTarifID, + Data: nil, + }) + return + } + + WriteJSON(w, http.StatusInternalServerError, Response{ + Success: false, + Message: "internal server error", + Data: nil, + }) + return + } + + WriteJSON(w, http.StatusOK, Response{ + Success: true, + Message: "ok", + Data: MappingHargaData{ + PeriodeTarifID: periodeTarifID, + DetailLayanan: rows, + }, + }) +} + +func parseLayananIDs(r *http.Request) []string { + values := queryValues(r, "layananID", "layananID[]") + if len(values) == 0 { + values = queryValues(r, "layanan_id", "layanan_id[]") + } + + seen := make(map[string]struct{}, len(values)) + ids := make([]string, 0, len(values)) + + for _, value := range values { + for _, part := range strings.Split(value, ",") { + id := strings.TrimSpace(part) + if id == "" { + continue + } + if _, exists := seen[id]; exists { + continue + } + seen[id] = struct{}{} + ids = append(ids, id) + } + } + + return ids +} + +func queryValues(r *http.Request, keys ...string) []string { + query := r.URL.Query() + values := make([]string, 0) + for _, key := range keys { + values = append(values, query[key]...) + } + return values +} + +func firstQueryValue(r *http.Request, keys ...string) (string, bool) { + query := r.URL.Query() + for _, key := range keys { + if values, exists := query[key]; exists && len(values) > 0 { + return values[0], true + } + } + return "", false +} diff --git a/internal/http/middleware.go b/internal/http/middleware.go new file mode 100644 index 0000000..f021334 --- /dev/null +++ b/internal/http/middleware.go @@ -0,0 +1,30 @@ +package http + +import ( + "net/http" + "strings" +) + +func BearerAuth(staticToken string, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if bearerToken(r.Header.Get("Authorization")) != staticToken { + WriteJSON(w, http.StatusUnauthorized, Response{ + Success: false, + Message: "Unauthenticated.", + Data: nil, + }) + return + } + + next.ServeHTTP(w, r) + }) +} + +func bearerToken(header string) string { + const prefix = "Bearer " + if !strings.HasPrefix(header, prefix) { + return "" + } + + return strings.TrimSpace(strings.TrimPrefix(header, prefix)) +} diff --git a/internal/http/response.go b/internal/http/response.go new file mode 100644 index 0000000..e070df7 --- /dev/null +++ b/internal/http/response.go @@ -0,0 +1,25 @@ +package http + +import ( + "encoding/json" + "net/http" + + "primaya-api/cpone-middleware/internal/repository" +) + +type Response struct { + Success bool `json:"success"` + Message string `json:"message"` + Data any `json:"data"` +} + +type MappingHargaData struct { + PeriodeTarifID string `json:"PeriodeTarifID"` + DetailLayanan []repository.TarifLayanan `json:"DetailLayanan"` +} + +func WriteJSON(w http.ResponseWriter, statusCode int, payload Response) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + _ = json.NewEncoder(w).Encode(payload) +} diff --git a/internal/repository/mysql.go b/internal/repository/mysql.go new file mode 100644 index 0000000..4ae2fd1 --- /dev/null +++ b/internal/repository/mysql.go @@ -0,0 +1,240 @@ +package repository + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" +) + +var ErrTarifTableNotFound = errors.New("tarif table not found") + +type MappingHargaParams struct { + PeriodeTarifID string + LayananIDs []string + Search string +} + +type TarifLayanan struct { + TarifLayananID int64 `json:"TarifLayananID"` + LayananID *string `json:"LayananID"` + NamaLayanan *string `json:"NamaLayanan"` + JenisLayananID *int64 `json:"JenisLayananID"` + NamaJenisLayanan *string `json:"NamaJenisLayanan"` + Kelas string `json:"Kelas"` + NamaKelas string `json:"NamaKelas"` + Harga *string `json:"Harga"` + TipeDiskonRJ string `json:"TipeDiskonRJ"` + DiskonRJ string `json:"DiskonRJ"` + TipeDiskonRI string `json:"TipeDiskonRI"` + DiskonRI string `json:"DiskonRI"` + TipeShare *string `json:"TipeShare"` + ShareDokter *string `json:"ShareDokter"` + ShareRS *string `json:"ShareRS"` + ShareExt *string `json:"ShareExt"` + ShareLain *string `json:"ShareLain"` + AsalTarif string `json:"AsalTarif"` +} + +type MySQLLayananRepository struct { + db *sql.DB +} + +func NewMySQLLayananRepository(db *sql.DB) MySQLLayananRepository { + return MySQLLayananRepository{db: db} +} + +func (r MySQLLayananRepository) MappingHarga(ctx context.Context, params MappingHargaParams) ([]TarifLayanan, error) { + tableName := "harga_layanan_" + params.PeriodeTarifID + + exists, err := r.tableExists(ctx, tableName) + if err != nil { + return nil, err + } + if !exists { + return nil, ErrTarifTableNotFound + } + + query, args := mappingHargaQuery(tableName, params.LayananIDs, params.Search) + rows, err := r.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + result := make([]TarifLayanan, 0) + seenLayananID := map[string]struct{}{} + + for rows.Next() { + row, err := scanTarifLayanan(rows) + if err != nil { + return nil, err + } + + layananID := stringValue(row.LayananID) + if layananID != "" { + if _, exists := seenLayananID[layananID]; exists { + continue + } + seenLayananID[layananID] = struct{}{} + } + + result = append(result, row) + } + + if err := rows.Err(); err != nil { + return nil, err + } + + return result, nil +} + +func (r MySQLLayananRepository) tableExists(ctx context.Context, tableName string) (bool, error) { + var count int + err := r.db.QueryRowContext(ctx, ` + SELECT COUNT(*) + FROM information_schema.tables + WHERE table_schema = DATABASE() + AND table_name = ? + `, tableName).Scan(&count) + if err != nil { + return false, err + } + + return count > 0, nil +} + +func mappingHargaQuery(tableName string, layananIDs []string, search string) (string, []any) { + args := make([]any, 0, len(layananIDs)+2) + placeholders := make([]string, 0, len(layananIDs)) + for _, id := range layananIDs { + placeholders = append(placeholders, "?") + args = append(args, id) + } + + var builder strings.Builder + builder.WriteString(` + SELECT + tarif.HargaLayananID AS TarifLayananID, + layanan_dept.LayananID, + layanan.Nama AS NamaLayanan, + layanan.JenisLayananID, + jenis.Nama AS NamaJenisLayanan, + 'Default' AS Kelas, + 'Default' AS NamaKelas, + tarif.Harga, + 'Percentage' AS TipeDiskonRJ, + '0.00' AS DiskonRJ, + 'Percentage' AS TipeDiskonRI, + '0.00' AS DiskonRI, + tarif.ShareTipe AS TipeShare, + tarif.ShareDokter, + tarif.ShareRS, + tarif.ShareExternal AS ShareExt, + tarif.ShareLain, + '+Layanan' AS AsalTarif + FROM layanandepartemen AS layanan_dept + INNER JOIN `) + builder.WriteString(tableName) + builder.WriteString(` AS tarif + ON tarif.LayananDepartemenID = layanan_dept.LayananDepartemenID + AND tarif.KelasID = 'Default' + AND tarif.NA = 'N' + LEFT JOIN masterlayanan AS layanan + ON layanan.LayananID = layanan_dept.LayananID + LEFT JOIN jenislayanan AS jenis + ON jenis.JenisLayananID = layanan.JenisLayananID + WHERE layanan_dept.NA = 'N' + AND layanan_dept.LayananID IN (` + strings.Join(placeholders, ",") + `) + `) + + if search != "" { + like := "%" + search + "%" + builder.WriteString(` + AND ( + layanan_dept.LayananID LIKE ? + OR layanan.Nama LIKE ? + ) + `) + args = append(args, like, like) + } + + builder.WriteString(` + ORDER BY layanan.Nama, layanan.JenisLayananID + `) + + return builder.String(), args +} + +func scanTarifLayanan(rows *sql.Rows) (TarifLayanan, error) { + var row TarifLayanan + var layananID sql.NullString + var namaLayanan sql.NullString + var jenisLayananID sql.NullInt64 + var namaJenisLayanan sql.NullString + var harga sql.NullString + var tipeShare sql.NullString + var shareDokter sql.NullString + var shareRS sql.NullString + var shareExt sql.NullString + var shareLain sql.NullString + + err := rows.Scan( + &row.TarifLayananID, + &layananID, + &namaLayanan, + &jenisLayananID, + &namaJenisLayanan, + &row.Kelas, + &row.NamaKelas, + &harga, + &row.TipeDiskonRJ, + &row.DiskonRJ, + &row.TipeDiskonRI, + &row.DiskonRI, + &tipeShare, + &shareDokter, + &shareRS, + &shareExt, + &shareLain, + &row.AsalTarif, + ) + if err != nil { + return TarifLayanan{}, fmt.Errorf("scan tarif layanan: %w", err) + } + + row.LayananID = nullStringPtr(layananID) + row.NamaLayanan = nullStringPtr(namaLayanan) + row.JenisLayananID = nullInt64Ptr(jenisLayananID) + row.NamaJenisLayanan = nullStringPtr(namaJenisLayanan) + row.Harga = nullStringPtr(harga) + row.TipeShare = nullStringPtr(tipeShare) + row.ShareDokter = nullStringPtr(shareDokter) + row.ShareRS = nullStringPtr(shareRS) + row.ShareExt = nullStringPtr(shareExt) + row.ShareLain = nullStringPtr(shareLain) + + return row, nil +} + +func nullStringPtr(value sql.NullString) *string { + if !value.Valid { + return nil + } + return &value.String +} + +func nullInt64Ptr(value sql.NullInt64) *int64 { + if !value.Valid { + return nil + } + return &value.Int64 +} + +func stringValue(value *string) string { + if value == nil { + return "" + } + return *value +} diff --git a/sqlc.yaml b/sqlc.yaml new file mode 100644 index 0000000..b2fe70f --- /dev/null +++ b/sqlc.yaml @@ -0,0 +1,11 @@ +version: "2" +sql: + - engine: "mysql" + schema: "sqlc/schema.sql" + queries: "sqlc/query.sql" + gen: + go: + package: "db" + out: "internal/db" + emit_json_tags: true + emit_prepared_queries: false diff --git a/sqlc/query.sql b/sqlc/query.sql new file mode 100644 index 0000000..35771da --- /dev/null +++ b/sqlc/query.sql @@ -0,0 +1,41 @@ +-- sqlc does not support dynamic table identifiers. This query is a generation +-- template for one concrete period table; duplicate or generate per period as +-- needed, then keep the HTTP response mapping unchanged. +-- +-- name: MappingHargaPeriode10 :many +SELECT + tarif.HargaLayananID AS TarifLayananID, + layanan_dept.LayananID, + layanan.Nama AS NamaLayanan, + layanan.JenisLayananID, + jenis.Nama AS NamaJenisLayanan, + 'Default' AS Kelas, + 'Default' AS NamaKelas, + tarif.Harga, + 'Percentage' AS TipeDiskonRJ, + '0.00' AS DiskonRJ, + 'Percentage' AS TipeDiskonRI, + '0.00' AS DiskonRI, + tarif.ShareTipe AS TipeShare, + tarif.ShareDokter, + tarif.ShareRS, + tarif.ShareExternal AS ShareExt, + tarif.ShareLain, + '+Layanan' AS AsalTarif +FROM layanandepartemen AS layanan_dept +INNER JOIN harga_layanan_10 AS tarif + ON tarif.LayananDepartemenID = layanan_dept.LayananDepartemenID + AND tarif.KelasID = 'Default' + AND tarif.NA = 'N' +LEFT JOIN masterlayanan AS layanan + ON layanan.LayananID = layanan_dept.LayananID +LEFT JOIN jenislayanan AS jenis + ON jenis.JenisLayananID = layanan.JenisLayananID +WHERE layanan_dept.NA = 'N' + AND layanan_dept.LayananID IN (sqlc.slice('layanan_ids')) + AND ( + sqlc.narg('search') IS NULL + OR layanan_dept.LayananID LIKE sqlc.narg('search') + OR layanan.Nama LIKE sqlc.narg('search') + ) +ORDER BY layanan.Nama, layanan.JenisLayananID; diff --git a/sqlc/schema.sql b/sqlc/schema.sql new file mode 100644 index 0000000..f9f7293 --- /dev/null +++ b/sqlc/schema.sql @@ -0,0 +1,29 @@ +CREATE TABLE layanandepartemen ( + LayananDepartemenID BIGINT NOT NULL, + LayananID VARCHAR(50), + NA CHAR(1) +); + +CREATE TABLE masterlayanan ( + LayananID VARCHAR(50) NOT NULL, + Nama VARCHAR(255), + JenisLayananID BIGINT +); + +CREATE TABLE jenislayanan ( + JenisLayananID BIGINT NOT NULL, + Nama VARCHAR(255) +); + +CREATE TABLE harga_layanan_10 ( + HargaLayananID BIGINT NOT NULL, + LayananDepartemenID BIGINT, + KelasID VARCHAR(50), + NA CHAR(1), + Harga DECIMAL(18,2), + ShareTipe VARCHAR(50), + ShareDokter DECIMAL(18,2), + ShareRS DECIMAL(18,2), + ShareExternal DECIMAL(18,2), + ShareLain DECIMAL(18,2) +);