Initial CPONE middleware implementation
This commit is contained in:
10
.env.example
Normal file
10
.env.example
Normal file
@@ -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
|
||||
18
.gitignore
vendored
Normal file
18
.gitignore
vendored
Normal file
@@ -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
|
||||
39
README.md
Normal file
39
README.md
Normal file
@@ -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 <your-secure-token>
|
||||
```
|
||||
|
||||
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.
|
||||
76
cmd/server/main.go
Normal file
76
cmd/server/main.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
7
go.mod
Normal file
7
go.mod
Normal file
@@ -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
|
||||
4
go.sum
Normal file
4
go.sum
Normal file
@@ -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=
|
||||
131
internal/config/config.go
Normal file
131
internal/config/config.go
Normal file
@@ -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
|
||||
}
|
||||
31
internal/db/db.go
Normal file
31
internal/db/db.go
Normal file
@@ -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,
|
||||
}
|
||||
}
|
||||
39
internal/db/models.go
Normal file
39
internal/db/models.go
Normal file
@@ -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"`
|
||||
}
|
||||
135
internal/db/query.sql.go
Normal file
135
internal/db/query.sql.go
Normal file
@@ -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
|
||||
}
|
||||
134
internal/http/handler.go
Normal file
134
internal/http/handler.go
Normal file
@@ -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
|
||||
}
|
||||
30
internal/http/middleware.go
Normal file
30
internal/http/middleware.go
Normal file
@@ -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))
|
||||
}
|
||||
25
internal/http/response.go
Normal file
25
internal/http/response.go
Normal file
@@ -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)
|
||||
}
|
||||
240
internal/repository/mysql.go
Normal file
240
internal/repository/mysql.go
Normal file
@@ -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
|
||||
}
|
||||
11
sqlc.yaml
Normal file
11
sqlc.yaml
Normal file
@@ -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
|
||||
41
sqlc/query.sql
Normal file
41
sqlc/query.sql
Normal file
@@ -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;
|
||||
29
sqlc/schema.sql
Normal file
29
sqlc/schema.sql
Normal file
@@ -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)
|
||||
);
|
||||
Reference in New Issue
Block a user