feat: require hospital code and add company APIs

This commit is contained in:
2026-08-28 10:30:08 +07:00
parent a59b2c6443
commit 8dd880003d
17 changed files with 867 additions and 142 deletions

View File

@@ -1,16 +1,7 @@
APP_HOST=0.0.0.0 APP_HOST=0.0.0.0
APP_PORT=8080 APP_PORT=8080
DB_HOST=127.0.0.1 # Database khusus master CPONE middleware.
DB_PORT=3306
DB_DATABASE=primaya
DB_USERNAME=root
DB_PASSWORD=
DB_DEFAULT_RS_CODE=AWALBROS_DEV
DB_DEFAULT_RS_NAME=RS Dev Awalbros
# Database khusus master CPONE middleware. Jika host/user/password dikosongkan,
# gunakan nilai DB_* di atas.
CPONE_DB_HOST=127.0.0.1 CPONE_DB_HOST=127.0.0.1
CPONE_DB_PORT=3306 CPONE_DB_PORT=3306
CPONE_DB_DATABASE=cpone_middleware CPONE_DB_DATABASE=cpone_middleware

View File

@@ -7,6 +7,16 @@ GET /api/cpone/paket
GET /api/cpone/paket/{id} GET /api/cpone/paket/{id}
Authorization: Bearer <your-secure-token> Authorization: Bearer <your-secure-token>
GET /api/cpone/layanan?page=1
Authorization: Bearer <your-secure-token>
X-RS-Code: <kode-rs>
GET /api/cpone/perusahaan?search=&page=1
GET /api/cpone/perusahaan/{id}
GET /api/cpone/perusahaan/{id}/group-tarif?group_tarif_id=
Authorization: Bearer <your-secure-token>
X-RS-Code: <kode-rs>
GET /api/cpone/tarif-kerjasama?search=&perusahaan_id=&tarif_id=&page=1&per_page=15 GET /api/cpone/tarif-kerjasama?search=&perusahaan_id=&tarif_id=&page=1&per_page=15
Authorization: Bearer <your-secure-token> Authorization: Bearer <your-secure-token>
@@ -134,14 +144,6 @@ Copy `.env.example` menjadi `.env`, lalu isi koneksi MySQL:
```text ```text
APP_HOST=0.0.0.0 APP_HOST=0.0.0.0
APP_PORT=8080 APP_PORT=8080
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=primaya
DB_USERNAME=root
DB_PASSWORD=
DB_DEFAULT_RS_CODE=AWALBROS_DEV
DB_DEFAULT_RS_NAME=RS Dev Awalbros
CPONE_DB_HOST=127.0.0.1 CPONE_DB_HOST=127.0.0.1
CPONE_DB_PORT=3306 CPONE_DB_PORT=3306
CPONE_DB_DATABASE=cpone_middleware CPONE_DB_DATABASE=cpone_middleware
@@ -152,9 +154,6 @@ CPONE_DATABASE_SETTINGS_TOKEN=replace-with-a-different-admin-token
CPONE_DATABASE_CREDENTIAL_KEY=replace-with-a-long-random-encryption-key CPONE_DATABASE_CREDENTIAL_KEY=replace-with-a-long-random-encryption-key
``` ```
`DB_*` tetap menjadi koneksi default (saat ini database dev Awalbros). Request
lama yang tidak mengirim kode RS akan tetap menggunakan koneksi ini.
## Multi-database per kode RS ## Multi-database per kode RS
Tambahkan atau perbarui koneksi RS melalui API berikut. Koneksi akan dites Tambahkan atau perbarui koneksi RS melalui API berikut. Koneksi akan dites
@@ -185,10 +184,10 @@ GET /api/cpone/database-settings/{kodeRs}
``` ```
Saat startup, service otomatis membuat database `CPONE_DB_DATABASE` beserta Saat startup, service otomatis membuat database `CPONE_DB_DATABASE` beserta
tabel `hospitals` dan `hospital_databases`. Koneksi HIS lama dari `DB_*` otomatis tabel `hospitals` dan `hospital_databases`. Tidak ada database RS default; semua
menjadi RS default. Akun `CPONE_DB_USERNAME` harus memiliki izin `CREATE koneksi HIS harus ditambahkan lewat API. Akun `CPONE_DB_USERNAME` harus memiliki
DATABASE` pada bootstrap pertama; alternatifnya jalankan migration izin `CREATE DATABASE` pada bootstrap pertama; alternatifnya jalankan migration
`database/migrations/001_create_cpone_management.sql` secara manual. di direktori `database/migrations` secara manual.
Response list/detail tidak pernah menampilkan password, hanya field Response list/detail tidak pernah menampilkan password, hanya field
`has_password`. Password koneksi RS disimpan terenkripsi AES-GCM menggunakan `has_password`. Password koneksi RS disimpan terenkripsi AES-GCM menggunakan
@@ -210,8 +209,9 @@ X-RS-Code: PRIMAYA_BEKASI_BARAT
Sebagai alternatif dapat memakai query `?kode_rs=PRIMAYA_BEKASI_BARAT`. Sebagai alternatif dapat memakai query `?kode_rs=PRIMAYA_BEKASI_BARAT`.
Header memiliki prioritas lebih tinggi daripada query. Response juga memuat Header memiliki prioritas lebih tinggi daripada query. Response juga memuat
header `X-RS-Code` agar koneksi yang terpilih mudah diaudit. Jika keduanya tidak header `X-RS-Code` agar koneksi yang terpilih mudah diaudit. Salah satu dari
ada, service memakai `DB_DEFAULT_RS_CODE`. header atau query tersebut wajib dikirim; jika tidak ada, API mengembalikan
status `422` dan tidak mengakses database HIS.
## Run ## Run

View File

@@ -25,10 +25,6 @@ func main() {
registry, err := databaseconfig.NewRegistry(bootstrapCtx, databaseconfig.Setting{ registry, err := databaseconfig.NewRegistry(bootstrapCtx, databaseconfig.Setting{
Host: cfg.ManagementDBHost, Port: cfg.ManagementDBPort, Database: cfg.ManagementDBDatabase, Host: cfg.ManagementDBHost, Port: cfg.ManagementDBPort, Database: cfg.ManagementDBDatabase,
Username: cfg.ManagementDBUsername, Password: cfg.ManagementDBPassword, Username: cfg.ManagementDBUsername, Password: cfg.ManagementDBPassword,
}, cfg.DBDefaultRSCode, databaseconfig.Setting{
RSCode: cfg.DBDefaultRSCode, Name: cfg.DBDefaultRSName,
Host: cfg.DBHost, Port: cfg.DBPort, Database: cfg.DBDatabase,
Username: cfg.DBUsername, Password: cfg.DBPassword,
}, databaseconfig.PoolConfig{ }, databaseconfig.PoolConfig{
MaxOpenConns: cfg.DBMaxOpenConns, MaxIdleConns: cfg.DBMaxIdleConns, MaxOpenConns: cfg.DBMaxOpenConns, MaxIdleConns: cfg.DBMaxIdleConns,
ConnMaxLifetime: cfg.DBConnMaxLifetime, ConnMaxLifetime: cfg.DBConnMaxLifetime,
@@ -39,14 +35,7 @@ func main() {
} }
defer registry.Close() defer registry.Close()
defaultDBCtx, defaultDBCancel := context.WithTimeout(context.Background(), 10*time.Second) handler := apphttp.NewDynamicHandler()
repo, err := registry.Resolve(defaultDBCtx, cfg.DBDefaultRSCode)
defaultDBCancel()
if err != nil {
log.Fatalf("ping mysql: %v", err)
}
handler := apphttp.NewHandler(repo)
settingsHandler := apphttp.NewDatabaseSettingsHandler(registry) settingsHandler := apphttp.NewDatabaseSettingsHandler(registry)
protected := func(handler http.Handler) http.Handler { protected := func(handler http.Handler) http.Handler {
return apphttp.BearerAuth(cfg.BearerToken, apphttp.SelectDatabase(registry, handler)) return apphttp.BearerAuth(cfg.BearerToken, apphttp.SelectDatabase(registry, handler))
@@ -58,6 +47,10 @@ func main() {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.Handle("GET /api/cpone/paket", protected(http.HandlerFunc(handler.ListPaket))) mux.Handle("GET /api/cpone/paket", protected(http.HandlerFunc(handler.ListPaket)))
mux.Handle("GET /api/cpone/paket/{id}", protected(http.HandlerFunc(handler.GetPaket))) mux.Handle("GET /api/cpone/paket/{id}", protected(http.HandlerFunc(handler.GetPaket)))
mux.Handle("GET /api/cpone/layanan", protected(http.HandlerFunc(handler.ListLayanan)))
mux.Handle("GET /api/cpone/perusahaan", protected(http.HandlerFunc(handler.ListPerusahaan)))
mux.Handle("GET /api/cpone/perusahaan/{id}", protected(http.HandlerFunc(handler.GetPerusahaan)))
mux.Handle("GET /api/cpone/perusahaan/{id}/group-tarif", protected(http.HandlerFunc(handler.GetPerusahaanGroupTarif)))
mux.Handle("GET /api/cpone/tarif-kerjasama", protected(http.HandlerFunc(handler.ListTarifKerjasama))) mux.Handle("GET /api/cpone/tarif-kerjasama", protected(http.HandlerFunc(handler.ListTarifKerjasama)))
mux.Handle("GET /api/cpone/tarif-kerjasama/{id}", protected(http.HandlerFunc(handler.GetTarifKerjasama))) mux.Handle("GET /api/cpone/tarif-kerjasama/{id}", protected(http.HandlerFunc(handler.GetTarifKerjasama)))
mux.Handle("GET /api/cpone/layanan-mapping-harga", protected(http.HandlerFunc(handler.MappingHarga))) mux.Handle("GET /api/cpone/layanan-mapping-harga", protected(http.HandlerFunc(handler.MappingHarga)))

View File

@@ -7,7 +7,6 @@ CREATE TABLE IF NOT EXISTS hospitals (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
code VARCHAR(50) NOT NULL, code VARCHAR(50) NOT NULL,
name VARCHAR(150) NOT NULL, name VARCHAR(150) NOT NULL,
is_default TINYINT(1) NOT NULL DEFAULT 0,
active TINYINT(1) NOT NULL DEFAULT 1, active TINYINT(1) NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,

View File

@@ -0,0 +1,17 @@
USE `cpone_middleware`;
SET @drop_default_column = IF(
EXISTS(
SELECT 1
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'hospitals'
AND column_name = 'is_default'
),
'ALTER TABLE hospitals DROP COLUMN is_default',
'SELECT 1'
);
PREPARE drop_default_statement FROM @drop_default_column;
EXECUTE drop_default_statement;
DEALLOCATE PREPARE drop_default_statement;

View File

@@ -12,18 +12,11 @@ import (
type Config struct { type Config struct {
AppHost string AppHost string
AppPort string AppPort string
DBHost string
DBPort string
DBDatabase string
DBUsername string
DBPassword string
BearerToken string BearerToken string
DBSettingsToken string DBSettingsToken string
DBMaxOpenConns int DBMaxOpenConns int
DBMaxIdleConns int DBMaxIdleConns int
DBConnMaxLifetime time.Duration DBConnMaxLifetime time.Duration
DBDefaultRSCode string
DBDefaultRSName string
ManagementDBHost string ManagementDBHost string
ManagementDBPort string ManagementDBPort string
ManagementDBDatabase string ManagementDBDatabase string
@@ -40,35 +33,22 @@ func Load(path string) (Config, error) {
cfg := Config{ cfg := Config{
AppHost: env("APP_HOST", "0.0.0.0"), AppHost: env("APP_HOST", "0.0.0.0"),
AppPort: env("APP_PORT", "8080"), 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", ""), BearerToken: env("CPONE_BEARER_TOKEN", ""),
DBSettingsToken: env("CPONE_DATABASE_SETTINGS_TOKEN", env("CPONE_BEARER_TOKEN", "")), DBSettingsToken: env("CPONE_DATABASE_SETTINGS_TOKEN", env("CPONE_BEARER_TOKEN", "")),
DBMaxOpenConns: envInt("DB_MAX_OPEN_CONNS", 25), DBMaxOpenConns: envInt("DB_MAX_OPEN_CONNS", 25),
DBMaxIdleConns: envInt("DB_MAX_IDLE_CONNS", 25), DBMaxIdleConns: envInt("DB_MAX_IDLE_CONNS", 25),
DBConnMaxLifetime: time.Duration(envInt("DB_CONN_MAX_LIFETIME_MINUTES", 5)) * time.Minute, DBConnMaxLifetime: time.Duration(envInt("DB_CONN_MAX_LIFETIME_MINUTES", 5)) * time.Minute,
DBDefaultRSCode: strings.ToUpper(strings.TrimSpace(env("DB_DEFAULT_RS_CODE", "AWALBROS_DEV"))), ManagementDBHost: env("CPONE_DB_HOST", "127.0.0.1"),
DBDefaultRSName: env("DB_DEFAULT_RS_NAME", "RS Dev Awalbros"), ManagementDBPort: env("CPONE_DB_PORT", "3306"),
ManagementDBHost: env("CPONE_DB_HOST", env("DB_HOST", "127.0.0.1")),
ManagementDBPort: env("CPONE_DB_PORT", env("DB_PORT", "3306")),
ManagementDBDatabase: env("CPONE_DB_DATABASE", "cpone_middleware"), ManagementDBDatabase: env("CPONE_DB_DATABASE", "cpone_middleware"),
ManagementDBUsername: env("CPONE_DB_USERNAME", env("DB_USERNAME", "root")), ManagementDBUsername: env("CPONE_DB_USERNAME", "root"),
ManagementDBPassword: env("CPONE_DB_PASSWORD", env("DB_PASSWORD", "")), ManagementDBPassword: env("CPONE_DB_PASSWORD", ""),
DBCredentialSecret: env("CPONE_DATABASE_CREDENTIAL_KEY", env("CPONE_DATABASE_SETTINGS_TOKEN", env("CPONE_BEARER_TOKEN", ""))), DBCredentialSecret: env("CPONE_DATABASE_CREDENTIAL_KEY", env("CPONE_DATABASE_SETTINGS_TOKEN", env("CPONE_BEARER_TOKEN", ""))),
} }
if cfg.DBDatabase == "" {
return Config{}, fmt.Errorf("DB_DATABASE wajib diisi")
}
if cfg.BearerToken == "" { if cfg.BearerToken == "" {
return Config{}, fmt.Errorf("CPONE_BEARER_TOKEN wajib diisi") return Config{}, fmt.Errorf("CPONE_BEARER_TOKEN wajib diisi")
} }
if cfg.DBDefaultRSCode == "" {
return Config{}, fmt.Errorf("DB_DEFAULT_RS_CODE wajib diisi")
}
if cfg.ManagementDBDatabase == "" { if cfg.ManagementDBDatabase == "" {
return Config{}, fmt.Errorf("CPONE_DB_DATABASE wajib diisi") return Config{}, fmt.Errorf("CPONE_DB_DATABASE wajib diisi")
} }
@@ -107,9 +87,11 @@ func loadDotEnv(path string) error {
value = strings.Trim(value, `"'`) value = strings.Trim(value, `"'`)
if key != "" { if key != "" {
if _, alreadySet := os.LookupEnv(key); !alreadySet {
_ = os.Setenv(key, value) _ = os.Setenv(key, value)
} }
} }
}
return scanner.Err() return scanner.Err()
} }

View File

@@ -49,7 +49,6 @@ type PublicSetting struct {
Database string `json:"database"` Database string `json:"database"`
Username string `json:"username"` Username string `json:"username"`
HasPassword bool `json:"has_password"` HasPassword bool `json:"has_password"`
IsDefault bool `json:"is_default"`
Active bool `json:"active"` Active bool `json:"active"`
} }
@@ -62,7 +61,6 @@ type PoolConfig struct {
type Registry struct { type Registry struct {
mu sync.RWMutex mu sync.RWMutex
managementDB *sql.DB managementDB *sql.DB
defaultCode string
poolConfig PoolConfig poolConfig PoolConfig
credentialKey [32]byte credentialKey [32]byte
settings map[string]Setting settings map[string]Setting
@@ -70,9 +68,9 @@ type Registry struct {
retiredPools []*sql.DB retiredPools []*sql.DB
} }
// NewRegistry creates the management database and tables, then seeds the // NewRegistry creates the management database and tables, then loads all
// existing HIS connection as the default hospital. // active hospital database settings.
func NewRegistry(ctx context.Context, managementSetting Setting, defaultCode string, defaultSetting Setting, poolConfig PoolConfig, credentialSecret string) (*Registry, error) { func NewRegistry(ctx context.Context, managementSetting Setting, poolConfig PoolConfig, credentialSecret string) (*Registry, error) {
if !validDatabaseName.MatchString(managementSetting.Database) { if !validDatabaseName.MatchString(managementSetting.Database) {
return nil, errors.New("nama database manajemen hanya boleh berisi huruf, angka, dan underscore") return nil, errors.New("nama database manajemen hanya boleh berisi huruf, angka, dan underscore")
} }
@@ -86,7 +84,6 @@ func NewRegistry(ctx context.Context, managementSetting Setting, defaultCode str
} }
r := &Registry{ r := &Registry{
managementDB: managementDB, managementDB: managementDB,
defaultCode: NormalizeRSCode(defaultCode),
poolConfig: poolConfig, poolConfig: poolConfig,
credentialKey: sha256.Sum256([]byte(credentialSecret)), credentialKey: sha256.Sum256([]byte(credentialSecret)),
settings: make(map[string]Setting), settings: make(map[string]Setting),
@@ -96,14 +93,6 @@ func NewRegistry(ctx context.Context, managementSetting Setting, defaultCode str
_ = managementDB.Close() _ = managementDB.Close()
return nil, err return nil, err
} }
defaultSetting.RSCode = r.defaultCode
if strings.TrimSpace(defaultSetting.Name) == "" {
defaultSetting.Name = "RS Dev Awalbros"
}
if err := r.seedDefault(ctx, defaultSetting); err != nil {
_ = managementDB.Close()
return nil, err
}
if err := r.load(ctx); err != nil { if err := r.load(ctx); err != nil {
_ = managementDB.Close() _ = managementDB.Close()
return nil, err return nil, err
@@ -113,13 +102,8 @@ func NewRegistry(ctx context.Context, managementSetting Setting, defaultCode str
func NormalizeRSCode(code string) string { return strings.ToUpper(strings.TrimSpace(code)) } func NormalizeRSCode(code string) string { return strings.ToUpper(strings.TrimSpace(code)) }
func (r *Registry) DefaultCode() string { return r.defaultCode }
func (r *Registry) Resolve(ctx context.Context, code string) (repository.MySQLLayananRepository, error) { func (r *Registry) Resolve(ctx context.Context, code string) (repository.MySQLLayananRepository, error) {
code = NormalizeRSCode(code) code = NormalizeRSCode(code)
if code == "" {
code = r.defaultCode
}
r.mu.RLock() r.mu.RLock()
setting, exists := r.settings[code] setting, exists := r.settings[code]
db := r.pools[code] db := r.pools[code]
@@ -174,7 +158,7 @@ func (r *Registry) Upsert(ctx context.Context, setting Setting) (PublicSetting,
return PublicSetting{}, false, err return PublicSetting{}, false, err
} }
if created { if created {
result, execErr := tx.ExecContext(ctx, `INSERT INTO hospitals (code, name, is_default, active) VALUES (?, ?, 0, 1)`, setting.RSCode, setting.Name) result, execErr := tx.ExecContext(ctx, `INSERT INTO hospitals (code, name, active) VALUES (?, ?, 1)`, setting.RSCode, setting.Name)
if execErr != nil { if execErr != nil {
_ = newDB.Close() _ = newDB.Close()
return PublicSetting{}, false, execErr return PublicSetting{}, false, execErr
@@ -211,7 +195,7 @@ func (r *Registry) Upsert(ctx context.Context, setting Setting) (PublicSetting,
r.retiredPools = append(r.retiredPools, oldPool) r.retiredPools = append(r.retiredPools, oldPool)
} }
r.mu.Unlock() r.mu.Unlock()
return publicSetting(setting, r.defaultCode), created, nil return publicSetting(setting), created, nil
} }
func (r *Registry) List() []PublicSetting { func (r *Registry) List() []PublicSetting {
@@ -219,7 +203,7 @@ func (r *Registry) List() []PublicSetting {
defer r.mu.RUnlock() defer r.mu.RUnlock()
result := make([]PublicSetting, 0, len(r.settings)) result := make([]PublicSetting, 0, len(r.settings))
for _, setting := range r.settings { for _, setting := range r.settings {
result = append(result, publicSetting(setting, r.defaultCode)) result = append(result, publicSetting(setting))
} }
sort.Slice(result, func(i, j int) bool { return result[i].RSCode < result[j].RSCode }) sort.Slice(result, func(i, j int) bool { return result[i].RSCode < result[j].RSCode })
return result return result
@@ -230,7 +214,7 @@ func (r *Registry) Get(code string) (PublicSetting, bool) {
r.mu.RLock() r.mu.RLock()
defer r.mu.RUnlock() defer r.mu.RUnlock()
setting, exists := r.settings[code] setting, exists := r.settings[code]
return publicSetting(setting, r.defaultCode), exists return publicSetting(setting), exists
} }
func (r *Registry) Close() error { func (r *Registry) Close() error {
@@ -287,7 +271,7 @@ func (r *Registry) migrate(ctx context.Context) error {
statements := []string{ statements := []string{
`CREATE TABLE IF NOT EXISTS hospitals ( `CREATE TABLE IF NOT EXISTS hospitals (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, code VARCHAR(50) NOT NULL, name VARCHAR(150) NOT NULL, id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, code VARCHAR(50) NOT NULL, name VARCHAR(150) NOT NULL,
is_default TINYINT(1) NOT NULL DEFAULT 0, active TINYINT(1) NOT NULL DEFAULT 1, active TINYINT(1) NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id), UNIQUE KEY uq_hospitals_code (code), KEY idx_hospitals_active (active) PRIMARY KEY (id), UNIQUE KEY uq_hospitals_code (code), KEY idx_hospitals_active (active)
@@ -307,44 +291,19 @@ func (r *Registry) migrate(ctx context.Context) error {
return fmt.Errorf("migrasi database manajemen: %w", err) return fmt.Errorf("migrasi database manajemen: %w", err)
} }
} }
var defaultColumnExists int
if err := r.managementDB.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'hospitals' AND column_name = 'is_default'`).Scan(&defaultColumnExists); err != nil {
return fmt.Errorf("periksa schema database manajemen: %w", err)
}
if defaultColumnExists > 0 {
if _, err := r.managementDB.ExecContext(ctx, `ALTER TABLE hospitals DROP COLUMN is_default`); err != nil {
return fmt.Errorf("hapus kolom default rumah sakit: %w", err)
}
}
return nil return nil
} }
func (r *Registry) seedDefault(ctx context.Context, setting Setting) error {
setting = normalizeSetting(setting)
if err := Validate(setting); err != nil {
return fmt.Errorf("setting database default tidak valid: %w", err)
}
encryptedPassword, err := r.encrypt(setting.Password)
if err != nil {
return err
}
tx, err := r.managementDB.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, `UPDATE hospitals SET is_default = 0 WHERE code <> ?`, setting.RSCode); err != nil {
return err
}
_, err = tx.ExecContext(ctx, `INSERT INTO hospitals (code, name, is_default, active) VALUES (?, ?, 1, 1)
ON DUPLICATE KEY UPDATE is_default = 1, active = 1`, setting.RSCode, setting.Name)
if err != nil {
return err
}
var hospitalID int64
if err := tx.QueryRowContext(ctx, `SELECT id FROM hospitals WHERE code = ?`, setting.RSCode).Scan(&hospitalID); err != nil {
return err
}
_, err = tx.ExecContext(ctx, `INSERT INTO hospital_databases (hospital_id, host, port, database_name, username, encrypted_password)
VALUES (?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE hospital_id = hospital_id`, hospitalID, setting.Host,
setting.Port, setting.Database, setting.Username, encryptedPassword)
if err != nil {
return err
}
return tx.Commit()
}
func (r *Registry) load(ctx context.Context) error { func (r *Registry) load(ctx context.Context) error {
rows, err := r.managementDB.QueryContext(ctx, `SELECT h.code, h.name, d.host, d.port, d.database_name, d.username, d.encrypted_password rows, err := r.managementDB.QueryContext(ctx, `SELECT h.code, h.name, d.host, d.port, d.database_name, d.username, d.encrypted_password
FROM hospitals h INNER JOIN hospital_databases d ON d.hospital_id = h.id WHERE h.active = 1 ORDER BY h.code`) FROM hospitals h INNER JOIN hospital_databases d ON d.hospital_id = h.id WHERE h.active = 1 ORDER BY h.code`)
@@ -464,10 +423,10 @@ func normalizeSetting(setting Setting) Setting {
return setting return setting
} }
func publicSetting(setting Setting, defaultCode string) PublicSetting { func publicSetting(setting Setting) PublicSetting {
return PublicSetting{RSCode: setting.RSCode, Name: setting.Name, Host: setting.Host, Port: setting.Port, return PublicSetting{RSCode: setting.RSCode, Name: setting.Name, Host: setting.Host, Port: setting.Port,
Database: setting.Database, Username: setting.Username, HasPassword: setting.Password != "", Database: setting.Database, Username: setting.Username, HasPassword: setting.Password != "",
IsDefault: setting.RSCode == defaultCode, Active: true} Active: true}
} }
func mysqlDSN(setting Setting) string { func mysqlDSN(setting Setting) string {

View File

@@ -13,6 +13,7 @@ import (
var digitsOnly = regexp.MustCompile(`^\d+$`) var digitsOnly = regexp.MustCompile(`^\d+$`)
type LayananRepository interface { type LayananRepository interface {
ListLayanan(ctx context.Context, params repository.LayananListParams) (repository.LayananPage, error)
MappingHarga(ctx context.Context, params repository.MappingHargaParams) ([]repository.TarifLayanan, error) MappingHarga(ctx context.Context, params repository.MappingHargaParams) ([]repository.TarifLayanan, error)
} }
@@ -40,6 +41,12 @@ type TarifKerjasamaRepository interface {
GetTarifKerjasama(ctx context.Context, id int64, params repository.TarifKerjasamaDetailParams) (*repository.TarifKerjasamaDetail, error) GetTarifKerjasama(ctx context.Context, id int64, params repository.TarifKerjasamaDetailParams) (*repository.TarifKerjasamaDetail, error)
} }
type PerusahaanRepository interface {
ListPerusahaan(ctx context.Context, params repository.PerusahaanListParams) (repository.PerusahaanPage, error)
GetPerusahaan(ctx context.Context, id string) (*repository.PerusahaanDetail, error)
GetPerusahaanGroupTarif(ctx context.Context, id, groupTarifID string) (*repository.PerusahaanGroupTarifDetail, error)
}
type Handler struct { type Handler struct {
layananRepo LayananRepository layananRepo LayananRepository
labRepo LabRegistrationRepository labRepo LabRegistrationRepository
@@ -47,6 +54,7 @@ type Handler struct {
labResultRepo LabResultRepository labResultRepo LabResultRepository
paketRepo PaketRepository paketRepo PaketRepository
tarifRepo TarifKerjasamaRepository tarifRepo TarifKerjasamaRepository
perusahaanRepo PerusahaanRepository
} }
type CPONERepository interface { type CPONERepository interface {
@@ -56,15 +64,18 @@ type CPONERepository interface {
LabResultRepository LabResultRepository
PaketRepository PaketRepository
TarifKerjasamaRepository TarifKerjasamaRepository
PerusahaanRepository
} }
func NewHandler(repo CPONERepository) Handler { func NewHandler(repo CPONERepository) Handler {
return Handler{ return Handler{
layananRepo: repo, labRepo: repo, patientRepo: repo, layananRepo: repo, labRepo: repo, patientRepo: repo,
labResultRepo: repo, paketRepo: repo, tarifRepo: repo, labResultRepo: repo, paketRepo: repo, tarifRepo: repo, perusahaanRepo: repo,
} }
} }
func NewDynamicHandler() Handler { return Handler{} }
func (h Handler) layananRepository(ctx context.Context) LayananRepository { func (h Handler) layananRepository(ctx context.Context) LayananRepository {
if repo, ok := selectedRepository(ctx); ok { if repo, ok := selectedRepository(ctx); ok {
return repo return repo
@@ -107,6 +118,13 @@ func (h Handler) tarifRepository(ctx context.Context) TarifKerjasamaRepository {
return h.tarifRepo return h.tarifRepo
} }
func (h Handler) perusahaanRepository(ctx context.Context) PerusahaanRepository {
if repo, ok := selectedRepository(ctx); ok {
return repo
}
return h.perusahaanRepo
}
func (h Handler) MappingHarga(w http.ResponseWriter, r *http.Request) { func (h Handler) MappingHarga(w http.ResponseWriter, r *http.Request) {
layananIDs := parseLayananIDs(r) layananIDs := parseLayananIDs(r)
if len(layananIDs) == 0 { if len(layananIDs) == 0 {

65
internal/http/layanan.go Normal file
View File

@@ -0,0 +1,65 @@
package http
import (
"net/http"
"strconv"
"primaya-api/cpone-middleware/internal/repository"
)
const layananPerPage = 15
type LayananPaginationResponse struct {
CurrentPage int `json:"current_page"`
Data []repository.LayananDepartemen `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"`
}
func (h Handler) ListLayanan(w http.ResponseWriter, r *http.Request) {
pageNumber := layananPageNumber(r)
page, err := h.layananRepository(r.Context()).ListLayanan(r.Context(), repository.LayananListParams{
Page: pageNumber, PerPage: layananPerPage,
})
if err != nil {
WriteJSON(w, http.StatusInternalServerError, Response{
Success: false, Message: "internal server error", Data: nil,
})
return
}
lastPage := (page.Total + layananPerPage - 1) / layananPerPage
if lastPage < 1 {
lastPage = 1
}
var from *int
if len(page.Items) > 0 {
value := (pageNumber-1)*layananPerPage + 1
from = &value
}
var nextPageURL *string
if pageNumber < lastPage {
value := currentRequestURL(r, pageNumber+1)
nextPageURL = &value
}
WriteJSON(w, http.StatusOK, LayananPaginationResponse{
CurrentPage: pageNumber, Data: page.Items, FirstPageURL: "", From: from,
LastPage: lastPage, LastPageURL: "", Links: map[string]any{},
NextPageURL: nextPageURL, Total: page.Total, PerPage: layananPerPage,
})
}
func layananPageNumber(r *http.Request) int {
page, err := strconv.Atoi(r.URL.Query().Get("page"))
if err != nil || page < 1 {
return 1
}
return page
}

View File

@@ -0,0 +1,82 @@
package http
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"primaya-api/cpone-middleware/internal/repository"
)
type fakeLayananRepository struct {
page repository.LayananPage
err error
params repository.LayananListParams
}
func (f *fakeLayananRepository) ListLayanan(_ context.Context, params repository.LayananListParams) (repository.LayananPage, error) {
f.params = params
return f.page, f.err
}
func (f *fakeLayananRepository) MappingHarga(context.Context, repository.MappingHargaParams) ([]repository.TarifLayanan, error) {
return nil, nil
}
func TestListLayananResponseParity(t *testing.T) {
group, department := "MCU", "MEDICAL CHECK UP"
layananID, name, price := "6413", "DARAH LENGKAP", "125000.00"
repo := &fakeLayananRepository{page: repository.LayananPage{
Total: 16,
Items: []repository.LayananDepartemen{{
GroupLayananID: &group, Departemen: &department, LayananDepartemenID: 987,
LayananID: &layananID, NamaLayanan: &name, Harga: &price,
}},
}}
handler := Handler{layananRepo: repo}
request := httptest.NewRequest(http.MethodGet, "http://localhost/api/cpone/layanan?page=1&kode_rs=AWALBROS_DEV", nil)
recorder := httptest.NewRecorder()
handler.ListLayanan(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
}
if repo.params.Page != 1 || repo.params.PerPage != 15 {
t.Fatalf("params = %+v", repo.params)
}
for _, expected := range []string{
`"GroupLayananID":"MCU"`, `"Departemen":"MEDICAL CHECK UP"`,
`"LayananDepartemenID":987`, `"LayananID":"6413"`,
`"NamaLayanan":"DARAH LENGKAP"`, `"Harga":"125000.00"`,
`"last_page":2`, `"per_page":15`,
`"next_page_url":"http://localhost/api/cpone/layanan?kode_rs=AWALBROS_DEV\u0026page=2"`,
} {
if !strings.Contains(recorder.Body.String(), expected) {
t.Fatalf("response missing %s: %s", expected, recorder.Body.String())
}
}
}
func TestListLayananUsesLaravelPageFallback(t *testing.T) {
repo := &fakeLayananRepository{page: repository.LayananPage{Items: []repository.LayananDepartemen{}}}
handler := Handler{layananRepo: repo}
for _, target := range []string{"/api/cpone/layanan", "/api/cpone/layanan?page=0", "/api/cpone/layanan?page=invalid"} {
recorder := httptest.NewRecorder()
handler.ListLayanan(recorder, httptest.NewRequest(http.MethodGet, target, nil))
if recorder.Code != http.StatusOK || repo.params.Page != 1 || repo.params.PerPage != layananPerPage {
t.Fatalf("target=%s status=%d params=%+v", target, recorder.Code, repo.params)
}
}
}
func TestListLayananInternalError(t *testing.T) {
handler := Handler{layananRepo: &fakeLayananRepository{err: errors.New("query failed")}}
recorder := httptest.NewRecorder()
handler.ListLayanan(recorder, httptest.NewRequest(http.MethodGet, "/api/cpone/layanan", nil))
if recorder.Code != http.StatusInternalServerError {
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
}
}

View File

@@ -13,7 +13,6 @@ import (
type selectedRepositoryKey struct{} type selectedRepositoryKey struct{}
type DatabaseResolver interface { type DatabaseResolver interface {
DefaultCode() string
Resolve(ctx context.Context, code string) (repository.MySQLLayananRepository, error) Resolve(ctx context.Context, code string) (repository.MySQLLayananRepository, error)
} }
@@ -39,7 +38,9 @@ func SelectDatabase(resolver DatabaseResolver, next http.Handler) http.Handler {
code = strings.TrimSpace(r.URL.Query().Get("kode_rs")) code = strings.TrimSpace(r.URL.Query().Get("kode_rs"))
} }
if code == "" { if code == "" {
code = resolver.DefaultCode() WriteJSON(w, http.StatusUnprocessableEntity, Response{Success: false,
Message: "Kode RS wajib diisi melalui header X-RS-Code atau parameter kode_rs", Data: nil})
return
} }
code = databaseconfig.NormalizeRSCode(code) code = databaseconfig.NormalizeRSCode(code)

View File

@@ -12,28 +12,25 @@ import (
) )
type fakeDatabaseResolver struct { type fakeDatabaseResolver struct {
defaultCode string
resolvedCode string resolvedCode string
err error err error
} }
func (f *fakeDatabaseResolver) DefaultCode() string { return f.defaultCode }
func (f *fakeDatabaseResolver) Resolve(_ context.Context, code string) (repository.MySQLLayananRepository, error) { func (f *fakeDatabaseResolver) Resolve(_ context.Context, code string) (repository.MySQLLayananRepository, error) {
f.resolvedCode = code f.resolvedCode = code
return repository.MySQLLayananRepository{}, f.err return repository.MySQLLayananRepository{}, f.err
} }
func TestSelectDatabasePriorityAndDefault(t *testing.T) { func TestSelectDatabasePriority(t *testing.T) {
tests := []struct { tests := []struct {
name, target, header, want string name, target, header, want string
}{ }{
{name: "header", target: "/resource?kode_rs=QUERY", header: " rs_header ", want: "RS_HEADER"}, {name: "header", target: "/resource?kode_rs=QUERY", header: " rs_header ", want: "RS_HEADER"},
{name: "query", target: "/resource?kode_rs=rs_query", want: "RS_QUERY"}, {name: "query", target: "/resource?kode_rs=rs_query", want: "RS_QUERY"},
{name: "default", target: "/resource", want: "AWALBROS_DEV"},
} }
for _, test := range tests { for _, test := range tests {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
resolver := &fakeDatabaseResolver{defaultCode: "AWALBROS_DEV"} resolver := &fakeDatabaseResolver{}
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if _, ok := selectedRepository(r.Context()); !ok { if _, ok := selectedRepository(r.Context()); !ok {
t.Fatal("selected repository missing from context") t.Fatal("selected repository missing from context")
@@ -54,8 +51,23 @@ func TestSelectDatabasePriorityAndDefault(t *testing.T) {
} }
} }
func TestSelectDatabaseRequiresRSCode(t *testing.T) {
resolver := &fakeDatabaseResolver{}
request := httptest.NewRequest(http.MethodGet, "/resource", nil)
recorder := httptest.NewRecorder()
SelectDatabase(resolver, http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
t.Fatal("next handler should not be called")
})).ServeHTTP(recorder, request)
if recorder.Code != http.StatusUnprocessableEntity {
t.Fatalf("status = %d, body=%s", recorder.Code, recorder.Body.String())
}
if resolver.resolvedCode != "" {
t.Fatalf("resolver called with %q", resolver.resolvedCode)
}
}
func TestSelectDatabaseUnknownCode(t *testing.T) { func TestSelectDatabaseUnknownCode(t *testing.T) {
resolver := &fakeDatabaseResolver{defaultCode: "DEFAULT", err: databaseconfig.ErrSettingNotFound} resolver := &fakeDatabaseResolver{err: databaseconfig.ErrSettingNotFound}
request := httptest.NewRequest(http.MethodGet, "/resource?kode_rs=unknown", nil) request := httptest.NewRequest(http.MethodGet, "/resource?kode_rs=unknown", nil)
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
SelectDatabase(resolver, http.HandlerFunc(func(http.ResponseWriter, *http.Request) { SelectDatabase(resolver, http.HandlerFunc(func(http.ResponseWriter, *http.Request) {

View File

@@ -171,7 +171,12 @@ func currentRequestURL(r *http.Request, page int) string {
if host == "" { if host == "" {
host = "localhost" host = "localhost"
} }
return (&url.URL{Scheme: scheme, Host: host, Path: r.URL.Path, RawQuery: "page=" + strconv.Itoa(page)}).String() query := url.Values{}
if code := r.URL.Query().Get("kode_rs"); code != "" {
query.Set("kode_rs", code)
}
query.Set("page", strconv.Itoa(page))
return (&url.URL{Scheme: scheme, Host: host, Path: r.URL.Path, RawQuery: query.Encode()}).String()
} }
func writePaketValidationError(w http.ResponseWriter, message string) { func writePaketValidationError(w http.ResponseWriter, message string) {

View File

@@ -0,0 +1,98 @@
package http
import (
"net/http"
"strconv"
"strings"
"primaya-api/cpone-middleware/internal/repository"
)
const perusahaanPerPage = 15
type PerusahaanPaginationResponse struct {
CurrentPage int `json:"current_page"`
Data []repository.PerusahaanSummary `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"`
}
func (h Handler) ListPerusahaan(w http.ResponseWriter, r *http.Request) {
pageNumber := perusahaanPageNumber(r)
page, err := h.perusahaanRepository(r.Context()).ListPerusahaan(r.Context(), repository.PerusahaanListParams{
Search: strings.TrimSpace(r.URL.Query().Get("search")), Page: pageNumber, PerPage: perusahaanPerPage,
})
if err != nil {
writePerusahaanInternalError(w)
return
}
lastPage := (page.Total + perusahaanPerPage - 1) / perusahaanPerPage
if lastPage < 1 {
lastPage = 1
}
var from *int
if len(page.Items) > 0 {
value := (pageNumber-1)*perusahaanPerPage + 1
from = &value
}
var nextPageURL *string
if pageNumber < lastPage {
value := currentRequestURL(r, pageNumber+1)
nextPageURL = &value
}
WriteJSON(w, http.StatusOK, PerusahaanPaginationResponse{
CurrentPage: pageNumber, Data: page.Items, FirstPageURL: "", From: from,
LastPage: lastPage, LastPageURL: "", Links: map[string]any{}, NextPageURL: nextPageURL,
Total: page.Total, PerPage: perusahaanPerPage,
})
}
func (h Handler) GetPerusahaan(w http.ResponseWriter, r *http.Request) {
detail, err := h.perusahaanRepository(r.Context()).GetPerusahaan(r.Context(), strings.TrimSpace(r.PathValue("id")))
if err != nil {
writePerusahaanInternalError(w)
return
}
if detail == nil {
writePerusahaanNotFound(w)
return
}
WriteJSON(w, http.StatusOK, Response{Success: true, Message: "ok", Data: detail})
}
func (h Handler) GetPerusahaanGroupTarif(w http.ResponseWriter, r *http.Request) {
detail, err := h.perusahaanRepository(r.Context()).GetPerusahaanGroupTarif(
r.Context(), strings.TrimSpace(r.PathValue("id")), strings.TrimSpace(r.URL.Query().Get("group_tarif_id")),
)
if err != nil {
writePerusahaanInternalError(w)
return
}
if detail == nil {
writePerusahaanNotFound(w)
return
}
WriteJSON(w, http.StatusOK, Response{Success: true, Message: "ok", Data: detail})
}
func perusahaanPageNumber(r *http.Request) int {
page, err := strconv.Atoi(r.URL.Query().Get("page"))
if err != nil || page < 1 {
return 1
}
return page
}
func writePerusahaanNotFound(w http.ResponseWriter) {
WriteJSON(w, http.StatusNotFound, Response{Success: false, Message: "Data tidak ditemukan", Data: nil})
}
func writePerusahaanInternalError(w http.ResponseWriter) {
WriteJSON(w, http.StatusInternalServerError, Response{Success: false, Message: "internal server error", Data: nil})
}

View File

@@ -0,0 +1,142 @@
package http
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"primaya-api/cpone-middleware/internal/repository"
)
type fakePerusahaanRepository struct {
page repository.PerusahaanPage
detail *repository.PerusahaanDetail
groupDetail *repository.PerusahaanGroupTarifDetail
err error
listParams repository.PerusahaanListParams
detailID string
groupID string
groupTarifID string
}
func (f *fakePerusahaanRepository) ListPerusahaan(_ context.Context, params repository.PerusahaanListParams) (repository.PerusahaanPage, error) {
f.listParams = params
return f.page, f.err
}
func (f *fakePerusahaanRepository) GetPerusahaan(_ context.Context, id string) (*repository.PerusahaanDetail, error) {
f.detailID = id
return f.detail, f.err
}
func (f *fakePerusahaanRepository) GetPerusahaanGroupTarif(_ context.Context, id, groupTarifID string) (*repository.PerusahaanGroupTarifDetail, error) {
f.groupID = id
f.groupTarifID = groupTarifID
return f.groupDetail, f.err
}
func TestListPerusahaanResponseParity(t *testing.T) {
repo := &fakePerusahaanRepository{page: repository.PerusahaanPage{
Total: 16,
Items: []repository.PerusahaanSummary{{
PerusahaanID: "AAL", KodePerusahaan: "INS-114", Nama: "ASURANSI ALLIANZ LIFE INDONESIA, PT",
}},
}}
handler := Handler{perusahaanRepo: repo}
request := httptest.NewRequest(http.MethodGet, "http://localhost/api/cpone/perusahaan?search=allianz&page=1&kode_rs=AWALBROS_DEV", nil)
recorder := httptest.NewRecorder()
handler.ListPerusahaan(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
}
if repo.listParams.Search != "allianz" || repo.listParams.Page != 1 || repo.listParams.PerPage != 15 {
t.Fatalf("params = %+v", repo.listParams)
}
for _, expected := range []string{
`"PerusahaanID":"AAL"`, `"KodePerusahaan":"INS-114"`,
`"Nama":"ASURANSI ALLIANZ LIFE INDONESIA, PT"`, `"last_page":2`,
`"next_page_url":"http://localhost/api/cpone/perusahaan?kode_rs=AWALBROS_DEV\u0026page=2"`,
} {
if !strings.Contains(recorder.Body.String(), expected) {
t.Fatalf("response missing %s: %s", expected, recorder.Body.String())
}
}
}
func TestGetPerusahaanByCode(t *testing.T) {
detail := &repository.PerusahaanDetail{
PerusahaanID: "AAL", KodePerusahaan: "INS-114", Nama: "ALLIANZ",
JenisPerusahaanID: 1, Penjamin: "Y", Asuransi: "Y", Provider: "N",
COB: "N", MataUangID: "IDR", DefaultDiskon: "0.00", PPN: "0.00",
FlagDiskon: "N", VerifikasiBilling: "N", NA: "N",
}
repo := &fakePerusahaanRepository{detail: detail}
handler := Handler{perusahaanRepo: repo}
request := httptest.NewRequest(http.MethodGet, "/api/cpone/perusahaan/INS-114", nil)
request.SetPathValue("id", "INS-114")
recorder := httptest.NewRecorder()
handler.GetPerusahaan(recorder, request)
if recorder.Code != http.StatusOK || repo.detailID != "INS-114" {
t.Fatalf("status=%d id=%q body=%s", recorder.Code, repo.detailID, recorder.Body.String())
}
for _, expected := range []string{`"success":true`, `"PerusahaanID":"AAL"`, `"ppn":"0.00"`, `"nomerRekening":null`} {
if !strings.Contains(recorder.Body.String(), expected) {
t.Fatalf("response missing %s: %s", expected, recorder.Body.String())
}
}
}
func TestGetPerusahaanGroupTarif(t *testing.T) {
name := "DARAH LENGKAP"
detail := &repository.PerusahaanGroupTarifDetail{
PerusahaanID: "AAL", KodePerusahaan: "INS-114", NamaPerusahaan: "ALLIANZ",
GroupTarif: []repository.PerusahaanGroupTarif{{
GroupTarifID: 27, NamaGroupTarif: "Tarif 2026", TanggalBerlaku: "2026-01-01",
TanggalBerakhir: "2026-12-31", PeriodeTarifID: 10, Keterangan: "Tarif 2026",
DetailLayanan: []repository.PerusahaanGroupTarifLayanan{{
TarifLayananID: 1, LayananID: "6413", NamaLayanan: &name,
JenisLayananID: 1, Kelas: "Default", NamaKelas: "Default", Harga: "100000.00",
}},
}},
}
repo := &fakePerusahaanRepository{groupDetail: detail}
handler := Handler{perusahaanRepo: repo}
request := httptest.NewRequest(http.MethodGet, "/api/cpone/perusahaan/AAL/group-tarif?group_tarif_id=27", nil)
request.SetPathValue("id", "AAL")
recorder := httptest.NewRecorder()
handler.GetPerusahaanGroupTarif(recorder, request)
if recorder.Code != http.StatusOK || repo.groupID != "AAL" || repo.groupTarifID != "27" {
t.Fatalf("status=%d company=%q group=%q body=%s", recorder.Code, repo.groupID, repo.groupTarifID, recorder.Body.String())
}
for _, expected := range []string{`"GroupTarifID":27`, `"NamaGroupTarif":"Tarif 2026"`, `"DetailLayanan":[`, `"NamaKelas":"Default"`} {
if !strings.Contains(recorder.Body.String(), expected) {
t.Fatalf("response missing %s: %s", expected, recorder.Body.String())
}
}
}
func TestPerusahaanNotFoundAndInternalError(t *testing.T) {
tests := []struct {
name string
repo *fakePerusahaanRepository
want int
}{
{name: "not found", repo: &fakePerusahaanRepository{}, want: http.StatusNotFound},
{name: "error", repo: &fakePerusahaanRepository{err: errors.New("query failed")}, want: http.StatusInternalServerError},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
handler := Handler{perusahaanRepo: test.repo}
request := httptest.NewRequest(http.MethodGet, "/api/cpone/perusahaan/UNKNOWN", nil)
request.SetPathValue("id", "UNKNOWN")
recorder := httptest.NewRecorder()
handler.GetPerusahaan(recorder, request)
if recorder.Code != test.want {
t.Fatalf("status=%d want=%d body=%s", recorder.Code, test.want, recorder.Body.String())
}
})
}
}

View File

@@ -0,0 +1,71 @@
package repository
import (
"context"
"database/sql"
)
type LayananListParams struct {
Page int
PerPage int
}
type LayananPage struct {
Items []LayananDepartemen
Total int
}
type LayananDepartemen struct {
GroupLayananID *string `json:"GroupLayananID"`
Departemen *string `json:"Departemen"`
LayananDepartemenID int64 `json:"LayananDepartemenID"`
LayananID *string `json:"LayananID"`
NamaLayanan *string `json:"NamaLayanan"`
Harga *string `json:"Harga"`
}
func (r MySQLLayananRepository) ListLayanan(ctx context.Context, params LayananListParams) (LayananPage, error) {
var total int
if err := r.db.QueryRowContext(ctx, `
SELECT COUNT(*)
FROM layanandepartemen
WHERE NA = 'N' AND DepartemenID = 'MCU'
`).Scan(&total); err != nil {
return LayananPage{}, err
}
rows, err := r.db.QueryContext(ctx, `
SELECT ld.GroupLayananID, d.Nama, ld.LayananDepartemenID,
ld.LayananID, ml.Nama, CAST(ld.Harga AS CHAR)
FROM layanandepartemen ld
LEFT JOIN departemen d ON d.DepartemenID = ld.DepartemenID
LEFT JOIN masterlayanan ml ON ml.LayananID = ld.LayananID
WHERE ld.NA = 'N' AND ld.DepartemenID = 'MCU'
ORDER BY ld.TanggalBuat DESC
LIMIT ? OFFSET ?
`, params.PerPage, (params.Page-1)*params.PerPage)
if err != nil {
return LayananPage{}, err
}
defer rows.Close()
items := make([]LayananDepartemen, 0)
for rows.Next() {
var item LayananDepartemen
var groupLayananID, departemen, layananID, namaLayanan, harga sql.NullString
if err := rows.Scan(&groupLayananID, &departemen, &item.LayananDepartemenID,
&layananID, &namaLayanan, &harga); err != nil {
return LayananPage{}, err
}
item.GroupLayananID = nullStringPtr(groupLayananID)
item.Departemen = nullStringPtr(departemen)
item.LayananID = nullStringPtr(layananID)
item.NamaLayanan = nullStringPtr(namaLayanan)
item.Harga = nullStringPtr(harga)
items = append(items, item)
}
if err := rows.Err(); err != nil {
return LayananPage{}, err
}
return LayananPage{Items: items, Total: total}, nil
}

View File

@@ -0,0 +1,290 @@
package repository
import (
"context"
"database/sql"
"fmt"
)
type PerusahaanListParams struct {
Search string
Page int
PerPage int
}
type PerusahaanPage struct {
Items []PerusahaanSummary
Total int
}
type PerusahaanSummary struct {
PerusahaanID string `json:"PerusahaanID"`
KodePerusahaan string `json:"KodePerusahaan"`
Nama string `json:"Nama"`
}
type PerusahaanDetail struct {
PerusahaanID string `json:"PerusahaanID"`
KodePerusahaan string `json:"KodePerusahaan"`
Nama string `json:"Nama"`
JenisPerusahaanID int64 `json:"JenisPerusahaanID"`
Penjamin string `json:"Penjamin"`
Asuransi string `json:"Asuransi"`
Provider string `json:"Provider"`
COB string `json:"COB"`
MataUangID string `json:"MataUangID"`
Penanggungjawab *string `json:"Penanggungjawab"`
TeleponPenanggungjawab *string `json:"TeleponPenanggungjawab"`
EmailPenanggungjawab *string `json:"EmailPenanggungjawab"`
Alamat *string `json:"Alamat"`
Kota *string `json:"Kota"`
KodePos *string `json:"KodePos"`
Propinsi *string `json:"Propinsi"`
Negara *string `json:"Negara"`
Telepon *string `json:"Telepon"`
Fax *string `json:"Fax"`
Email *string `json:"Email"`
Website *string `json:"Website"`
NamaBank *string `json:"NamaBank"`
NamaRekening *string `json:"NamaRekening"`
NomerRekening *string `json:"nomerRekening"`
DefaultLamaPembayaran int64 `json:"DefaultLamaPembayaran"`
DefaultDiskon string `json:"DefaultDiskon"`
PPN string `json:"ppn"`
Catatan *string `json:"Catatan"`
NPWP *string `json:"NPWP"`
FlagDiskon string `json:"FlagDiskon"`
VerifikasiBilling string `json:"VerifikasiBilling"`
PICMarketing *string `json:"PIC_Marketing"`
NA string `json:"NA"`
}
type PerusahaanGroupTarifDetail struct {
PerusahaanID string `json:"PerusahaanID"`
KodePerusahaan string `json:"KodePerusahaan"`
NamaPerusahaan string `json:"NamaPerusahaan"`
GroupTarif []PerusahaanGroupTarif `json:"GroupTarif"`
}
type PerusahaanGroupTarif struct {
GroupTarifID int64 `json:"GroupTarifID"`
NamaGroupTarif string `json:"NamaGroupTarif"`
TanggalBerlaku string `json:"TanggalBerlaku"`
TanggalBerakhir string `json:"TanggalBerakhir"`
PeriodeTarifID int64 `json:"PeriodeTarifID"`
Keterangan string `json:"Keterangan"`
DetailLayanan []PerusahaanGroupTarifLayanan `json:"DetailLayanan"`
}
type PerusahaanGroupTarifLayanan 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"`
}
func (r MySQLLayananRepository) ListPerusahaan(ctx context.Context, params PerusahaanListParams) (PerusahaanPage, error) {
where := "WHERE NA = 'N'"
args := make([]any, 0, 5)
if params.Search != "" {
where += " AND (Nama LIKE ? OR KodePerusahaan LIKE ? OR PerusahaanID LIKE ?)"
like := "%" + params.Search + "%"
args = append(args, like, like, like)
}
var total int
if err := r.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM perusahaan "+where, args...).Scan(&total); err != nil {
return PerusahaanPage{}, err
}
queryArgs := append(append([]any{}, args...), params.PerPage, (params.Page-1)*params.PerPage)
rows, err := r.db.QueryContext(ctx, `SELECT PerusahaanID, KodePerusahaan, Nama
FROM perusahaan `+where+` ORDER BY Nama LIMIT ? OFFSET ?`, queryArgs...)
if err != nil {
return PerusahaanPage{}, err
}
defer rows.Close()
items := make([]PerusahaanSummary, 0)
for rows.Next() {
var item PerusahaanSummary
if err := rows.Scan(&item.PerusahaanID, &item.KodePerusahaan, &item.Nama); err != nil {
return PerusahaanPage{}, err
}
items = append(items, item)
}
if err := rows.Err(); err != nil {
return PerusahaanPage{}, err
}
return PerusahaanPage{Items: items, Total: total}, nil
}
func (r MySQLLayananRepository) GetPerusahaan(ctx context.Context, id string) (*PerusahaanDetail, error) {
row := r.db.QueryRowContext(ctx, `SELECT PerusahaanID, KodePerusahaan, Nama, JenisPerusahaanID,
Penjamin, Asuransi, Provider, COB, MataUangID, Penanggungjawab, TeleponPenanggungjawab,
EmailPenanggungjawab, Alamat, Kota, KodePos, Propinsi, Negara, Telepon, Fax, Email,
Website, NamaBank, NamaRekening, NomerRekening, DefaultLamaPembayaran,
CAST(DefaultDiskon AS CHAR), CAST(ppn AS CHAR), Catatan, NPWP, FlagDiskon,
VerifikasiBilling, PIC_Marketing, NA
FROM perusahaan WHERE NA = 'N' AND (PerusahaanID = ? OR KodePerusahaan = ?) LIMIT 1`, id, id)
detail, err := scanPerusahaanDetail(row)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &detail, nil
}
func (r MySQLLayananRepository) GetPerusahaanGroupTarif(ctx context.Context, id, groupTarifID string) (*PerusahaanGroupTarifDetail, error) {
var result PerusahaanGroupTarifDetail
err := r.db.QueryRowContext(ctx, `SELECT PerusahaanID, KodePerusahaan, Nama FROM perusahaan
WHERE NA = 'N' AND (PerusahaanID = ? OR KodePerusahaan = ?) LIMIT 1`, id, id).
Scan(&result.PerusahaanID, &result.KodePerusahaan, &result.NamaPerusahaan)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
query := `SELECT pg.GroupTarifID, CAST(pg.TanggalBerlaku AS CHAR), CAST(pg.TanggalBerakhir AS CHAR),
pg.PeriodeTarifID, pg.Keterangan
FROM perusahaan_grouptarif pg
INNER JOIN perusahaan_tarif pt ON pt.Perusahaan_TarifID = pg.Perusahaan_TarifID
WHERE pt.PerusahaanID = ? AND pg.NA = 'N'
AND CURDATE() BETWEEN pg.TanggalBerlaku AND pg.TanggalBerakhir`
args := []any{result.PerusahaanID}
if groupTarifID != "" {
query += " AND pg.GroupTarifID = ?"
args = append(args, groupTarifID)
}
query += " 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()
result.GroupTarif = make([]PerusahaanGroupTarif, 0)
for rows.Next() {
var group PerusahaanGroupTarif
if err := rows.Scan(&group.GroupTarifID, &group.TanggalBerlaku, &group.TanggalBerakhir,
&group.PeriodeTarifID, &group.Keterangan); err != nil {
return nil, err
}
if group.Keterangan != "" {
group.NamaGroupTarif = group.Keterangan
} else {
group.NamaGroupTarif = fmt.Sprintf("Group Tarif %d", group.GroupTarifID)
}
group.DetailLayanan, err = r.perusahaanGroupTarifLayanan(ctx, group.GroupTarifID)
if err != nil {
return nil, err
}
result.GroupTarif = append(result.GroupTarif, group)
}
if err := rows.Err(); err != nil {
return nil, err
}
return &result, nil
}
func (r MySQLLayananRepository) perusahaanGroupTarifLayanan(ctx context.Context, groupTarifID int64) ([]PerusahaanGroupTarifLayanan, error) {
tableName := fmt.Sprintf("perusahaan_tarif_layanan_%d", groupTarifID)
exists, err := r.tableExists(ctx, tableName)
if err != nil {
return nil, err
}
if !exists {
return []PerusahaanGroupTarifLayanan{}, nil
}
query := fmt.Sprintf(`SELECT tarif.TarifLayananID, tarif.LayananID, layanan.Nama,
tarif.JenisLayananID, jenis.Nama, tarif.KelasID, 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.KelasID`, tableName)
rows, err := r.db.QueryContext(ctx, query)
if err != nil {
return nil, err
}
defer rows.Close()
result := make([]PerusahaanGroupTarifLayanan, 0)
for rows.Next() {
var item PerusahaanGroupTarifLayanan
var namaLayanan, namaJenisLayanan, namaKelas sql.NullString
if err := rows.Scan(&item.TarifLayananID, &item.LayananID, &namaLayanan,
&item.JenisLayananID, &namaJenisLayanan, &item.Kelas, &namaKelas,
&item.Harga, &item.TipeDiskonRJ, &item.DiskonRJ, &item.TipeDiskonRI,
&item.DiskonRI, &item.TipeShare, &item.ShareDokter, &item.ShareRS,
&item.ShareExt, &item.ShareLain, &item.AsalTarif); err != nil {
return nil, err
}
item.NamaLayanan = nullStringPtr(namaLayanan)
item.NamaJenisLayanan = nullStringPtr(namaJenisLayanan)
if namaKelas.Valid && namaKelas.String != "" {
item.NamaKelas = namaKelas.String
} else {
item.NamaKelas = item.Kelas
}
result = append(result, item)
}
if err := rows.Err(); err != nil {
return nil, err
}
return result, nil
}
func scanPerusahaanDetail(row interface{ Scan(...any) error }) (PerusahaanDetail, error) {
var result PerusahaanDetail
var penanggungjawab, teleponPJ, emailPJ, alamat, kota, kodePos, propinsi, negara sql.NullString
var telepon, fax, email, website, namaBank, namaRekening, nomerRekening sql.NullString
var catatan, npwp, picMarketing sql.NullString
err := row.Scan(&result.PerusahaanID, &result.KodePerusahaan, &result.Nama,
&result.JenisPerusahaanID, &result.Penjamin, &result.Asuransi, &result.Provider,
&result.COB, &result.MataUangID, &penanggungjawab, &teleponPJ, &emailPJ,
&alamat, &kota, &kodePos, &propinsi, &negara, &telepon, &fax, &email, &website,
&namaBank, &namaRekening, &nomerRekening, &result.DefaultLamaPembayaran,
&result.DefaultDiskon, &result.PPN, &catatan, &npwp, &result.FlagDiskon,
&result.VerifikasiBilling, &picMarketing, &result.NA)
if err != nil {
return PerusahaanDetail{}, err
}
result.Penanggungjawab = nullStringPtr(penanggungjawab)
result.TeleponPenanggungjawab = nullStringPtr(teleponPJ)
result.EmailPenanggungjawab = nullStringPtr(emailPJ)
result.Alamat = nullStringPtr(alamat)
result.Kota = nullStringPtr(kota)
result.KodePos = nullStringPtr(kodePos)
result.Propinsi = nullStringPtr(propinsi)
result.Negara = nullStringPtr(negara)
result.Telepon = nullStringPtr(telepon)
result.Fax = nullStringPtr(fax)
result.Email = nullStringPtr(email)
result.Website = nullStringPtr(website)
result.NamaBank = nullStringPtr(namaBank)
result.NamaRekening = nullStringPtr(namaRekening)
result.NomerRekening = nullStringPtr(nomerRekening)
result.Catatan = nullStringPtr(catatan)
result.NPWP = nullStringPtr(npwp)
result.PICMarketing = nullStringPtr(picMarketing)
return result, nil
}