From 8dd880003db5d35b0152a0db8ca176abbb0385c4 Mon Sep 17 00:00:00 2001 From: Tubagus Fajri Date: Fri, 28 Aug 2026 10:30:08 +0700 Subject: [PATCH] feat: require hospital code and add company APIs --- .env.example | 11 +- README.md | 34 +- cmd/server/main.go | 17 +- .../001_create_cpone_management.sql | 1 - .../002_remove_default_hospital.sql | 17 + internal/config/config.go | 32 +- internal/databaseconfig/registry.go | 81 ++--- internal/http/handler.go | 32 +- internal/http/layanan.go | 65 ++++ internal/http/layanan_test.go | 82 +++++ internal/http/middleware.go | 5 +- internal/http/middleware_database_test.go | 24 +- internal/http/paket.go | 7 +- internal/http/perusahaan.go | 98 ++++++ internal/http/perusahaan_test.go | 142 +++++++++ internal/repository/layanan.go | 71 +++++ internal/repository/perusahaan.go | 290 ++++++++++++++++++ 17 files changed, 867 insertions(+), 142 deletions(-) create mode 100644 database/migrations/002_remove_default_hospital.sql create mode 100644 internal/http/layanan.go create mode 100644 internal/http/layanan_test.go create mode 100644 internal/http/perusahaan.go create mode 100644 internal/http/perusahaan_test.go create mode 100644 internal/repository/layanan.go create mode 100644 internal/repository/perusahaan.go diff --git a/.env.example b/.env.example index d5168d8..c8d263d 100644 --- a/.env.example +++ b/.env.example @@ -1,16 +1,7 @@ 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= -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. +# Database khusus master CPONE middleware. CPONE_DB_HOST=127.0.0.1 CPONE_DB_PORT=3306 CPONE_DB_DATABASE=cpone_middleware diff --git a/README.md b/README.md index 4659635..4ffcae0 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,16 @@ GET /api/cpone/paket GET /api/cpone/paket/{id} Authorization: Bearer +GET /api/cpone/layanan?page=1 +Authorization: Bearer +X-RS-Code: + +GET /api/cpone/perusahaan?search=&page=1 +GET /api/cpone/perusahaan/{id} +GET /api/cpone/perusahaan/{id}/group-tarif?group_tarif_id= +Authorization: Bearer +X-RS-Code: + GET /api/cpone/tarif-kerjasama?search=&perusahaan_id=&tarif_id=&page=1&per_page=15 Authorization: Bearer @@ -134,14 +144,6 @@ 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= -DB_DEFAULT_RS_CODE=AWALBROS_DEV -DB_DEFAULT_RS_NAME=RS Dev Awalbros - CPONE_DB_HOST=127.0.0.1 CPONE_DB_PORT=3306 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 ``` -`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 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 -tabel `hospitals` dan `hospital_databases`. Koneksi HIS lama dari `DB_*` otomatis -menjadi RS default. Akun `CPONE_DB_USERNAME` harus memiliki izin `CREATE -DATABASE` pada bootstrap pertama; alternatifnya jalankan migration -`database/migrations/001_create_cpone_management.sql` secara manual. +tabel `hospitals` dan `hospital_databases`. Tidak ada database RS default; semua +koneksi HIS harus ditambahkan lewat API. Akun `CPONE_DB_USERNAME` harus memiliki +izin `CREATE DATABASE` pada bootstrap pertama; alternatifnya jalankan migration +di direktori `database/migrations` secara manual. Response list/detail tidak pernah menampilkan password, hanya field `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`. Header memiliki prioritas lebih tinggi daripada query. Response juga memuat -header `X-RS-Code` agar koneksi yang terpilih mudah diaudit. Jika keduanya tidak -ada, service memakai `DB_DEFAULT_RS_CODE`. +header `X-RS-Code` agar koneksi yang terpilih mudah diaudit. Salah satu dari +header atau query tersebut wajib dikirim; jika tidak ada, API mengembalikan +status `422` dan tidak mengakses database HIS. ## Run diff --git a/cmd/server/main.go b/cmd/server/main.go index eb8639d..b57ee47 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -25,10 +25,6 @@ func main() { registry, err := databaseconfig.NewRegistry(bootstrapCtx, databaseconfig.Setting{ Host: cfg.ManagementDBHost, Port: cfg.ManagementDBPort, Database: cfg.ManagementDBDatabase, 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{ MaxOpenConns: cfg.DBMaxOpenConns, MaxIdleConns: cfg.DBMaxIdleConns, ConnMaxLifetime: cfg.DBConnMaxLifetime, @@ -39,14 +35,7 @@ func main() { } defer registry.Close() - defaultDBCtx, defaultDBCancel := context.WithTimeout(context.Background(), 10*time.Second) - repo, err := registry.Resolve(defaultDBCtx, cfg.DBDefaultRSCode) - defaultDBCancel() - if err != nil { - log.Fatalf("ping mysql: %v", err) - } - - handler := apphttp.NewHandler(repo) + handler := apphttp.NewDynamicHandler() settingsHandler := apphttp.NewDatabaseSettingsHandler(registry) protected := func(handler http.Handler) http.Handler { return apphttp.BearerAuth(cfg.BearerToken, apphttp.SelectDatabase(registry, handler)) @@ -58,6 +47,10 @@ func main() { mux := http.NewServeMux() 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/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/{id}", protected(http.HandlerFunc(handler.GetTarifKerjasama))) mux.Handle("GET /api/cpone/layanan-mapping-harga", protected(http.HandlerFunc(handler.MappingHarga))) diff --git a/database/migrations/001_create_cpone_management.sql b/database/migrations/001_create_cpone_management.sql index 6206f02..5c6b22c 100644 --- a/database/migrations/001_create_cpone_management.sql +++ b/database/migrations/001_create_cpone_management.sql @@ -7,7 +7,6 @@ CREATE TABLE IF NOT EXISTS hospitals ( 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, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, diff --git a/database/migrations/002_remove_default_hospital.sql b/database/migrations/002_remove_default_hospital.sql new file mode 100644 index 0000000..ca7cd6e --- /dev/null +++ b/database/migrations/002_remove_default_hospital.sql @@ -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; diff --git a/internal/config/config.go b/internal/config/config.go index 6d8ab91..22b2a82 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -12,18 +12,11 @@ import ( type Config struct { AppHost string AppPort string - DBHost string - DBPort string - DBDatabase string - DBUsername string - DBPassword string BearerToken string DBSettingsToken string DBMaxOpenConns int DBMaxIdleConns int DBConnMaxLifetime time.Duration - DBDefaultRSCode string - DBDefaultRSName string ManagementDBHost string ManagementDBPort string ManagementDBDatabase string @@ -40,35 +33,22 @@ func Load(path string) (Config, error) { 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", ""), DBSettingsToken: env("CPONE_DATABASE_SETTINGS_TOKEN", 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, - DBDefaultRSCode: strings.ToUpper(strings.TrimSpace(env("DB_DEFAULT_RS_CODE", "AWALBROS_DEV"))), - DBDefaultRSName: env("DB_DEFAULT_RS_NAME", "RS Dev Awalbros"), - ManagementDBHost: env("CPONE_DB_HOST", env("DB_HOST", "127.0.0.1")), - ManagementDBPort: env("CPONE_DB_PORT", env("DB_PORT", "3306")), + ManagementDBHost: env("CPONE_DB_HOST", "127.0.0.1"), + ManagementDBPort: env("CPONE_DB_PORT", "3306"), ManagementDBDatabase: env("CPONE_DB_DATABASE", "cpone_middleware"), - ManagementDBUsername: env("CPONE_DB_USERNAME", env("DB_USERNAME", "root")), - ManagementDBPassword: env("CPONE_DB_PASSWORD", env("DB_PASSWORD", "")), + ManagementDBUsername: env("CPONE_DB_USERNAME", "root"), + ManagementDBPassword: env("CPONE_DB_PASSWORD", ""), 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 == "" { 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 == "" { return Config{}, fmt.Errorf("CPONE_DB_DATABASE wajib diisi") } @@ -107,7 +87,9 @@ func loadDotEnv(path string) error { value = strings.Trim(value, `"'`) if key != "" { - _ = os.Setenv(key, value) + if _, alreadySet := os.LookupEnv(key); !alreadySet { + _ = os.Setenv(key, value) + } } } diff --git a/internal/databaseconfig/registry.go b/internal/databaseconfig/registry.go index 8c07aeb..584274e 100644 --- a/internal/databaseconfig/registry.go +++ b/internal/databaseconfig/registry.go @@ -49,7 +49,6 @@ type PublicSetting struct { Database string `json:"database"` Username string `json:"username"` HasPassword bool `json:"has_password"` - IsDefault bool `json:"is_default"` Active bool `json:"active"` } @@ -62,7 +61,6 @@ type PoolConfig struct { type Registry struct { mu sync.RWMutex managementDB *sql.DB - defaultCode string poolConfig PoolConfig credentialKey [32]byte settings map[string]Setting @@ -70,9 +68,9 @@ type Registry struct { retiredPools []*sql.DB } -// NewRegistry creates the management database and tables, then seeds the -// existing HIS connection as the default hospital. -func NewRegistry(ctx context.Context, managementSetting Setting, defaultCode string, defaultSetting Setting, poolConfig PoolConfig, credentialSecret string) (*Registry, error) { +// NewRegistry creates the management database and tables, then loads all +// active hospital database settings. +func NewRegistry(ctx context.Context, managementSetting Setting, poolConfig PoolConfig, credentialSecret string) (*Registry, error) { if !validDatabaseName.MatchString(managementSetting.Database) { 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{ managementDB: managementDB, - defaultCode: NormalizeRSCode(defaultCode), poolConfig: poolConfig, credentialKey: sha256.Sum256([]byte(credentialSecret)), settings: make(map[string]Setting), @@ -96,14 +93,6 @@ func NewRegistry(ctx context.Context, managementSetting Setting, defaultCode str _ = managementDB.Close() 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 { _ = managementDB.Close() 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 (r *Registry) DefaultCode() string { return r.defaultCode } - func (r *Registry) Resolve(ctx context.Context, code string) (repository.MySQLLayananRepository, error) { code = NormalizeRSCode(code) - if code == "" { - code = r.defaultCode - } r.mu.RLock() setting, exists := r.settings[code] db := r.pools[code] @@ -174,7 +158,7 @@ func (r *Registry) Upsert(ctx context.Context, setting Setting) (PublicSetting, return PublicSetting{}, false, err } 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 { _ = newDB.Close() 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.mu.Unlock() - return publicSetting(setting, r.defaultCode), created, nil + return publicSetting(setting), created, nil } func (r *Registry) List() []PublicSetting { @@ -219,7 +203,7 @@ func (r *Registry) List() []PublicSetting { defer r.mu.RUnlock() result := make([]PublicSetting, 0, len(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 }) return result @@ -230,7 +214,7 @@ func (r *Registry) Get(code string) (PublicSetting, bool) { r.mu.RLock() defer r.mu.RUnlock() setting, exists := r.settings[code] - return publicSetting(setting, r.defaultCode), exists + return publicSetting(setting), exists } func (r *Registry) Close() error { @@ -287,7 +271,7 @@ func (r *Registry) migrate(ctx context.Context) error { statements := []string{ `CREATE TABLE IF NOT EXISTS hospitals ( 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, 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) @@ -307,44 +291,19 @@ func (r *Registry) migrate(ctx context.Context) error { 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 } -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 { 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`) @@ -464,10 +423,10 @@ func normalizeSetting(setting Setting) 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, Database: setting.Database, Username: setting.Username, HasPassword: setting.Password != "", - IsDefault: setting.RSCode == defaultCode, Active: true} + Active: true} } func mysqlDSN(setting Setting) string { diff --git a/internal/http/handler.go b/internal/http/handler.go index 741cda5..3a95d12 100644 --- a/internal/http/handler.go +++ b/internal/http/handler.go @@ -13,6 +13,7 @@ import ( var digitsOnly = regexp.MustCompile(`^\d+$`) type LayananRepository interface { + ListLayanan(ctx context.Context, params repository.LayananListParams) (repository.LayananPage, error) MappingHarga(ctx context.Context, params repository.MappingHargaParams) ([]repository.TarifLayanan, error) } @@ -40,13 +41,20 @@ type TarifKerjasamaRepository interface { 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 { - layananRepo LayananRepository - labRepo LabRegistrationRepository - patientRepo PatientRepository - labResultRepo LabResultRepository - paketRepo PaketRepository - tarifRepo TarifKerjasamaRepository + layananRepo LayananRepository + labRepo LabRegistrationRepository + patientRepo PatientRepository + labResultRepo LabResultRepository + paketRepo PaketRepository + tarifRepo TarifKerjasamaRepository + perusahaanRepo PerusahaanRepository } type CPONERepository interface { @@ -56,15 +64,18 @@ type CPONERepository interface { LabResultRepository PaketRepository TarifKerjasamaRepository + PerusahaanRepository } func NewHandler(repo CPONERepository) Handler { return Handler{ 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 { if repo, ok := selectedRepository(ctx); ok { return repo @@ -107,6 +118,13 @@ func (h Handler) tarifRepository(ctx context.Context) TarifKerjasamaRepository { 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) { layananIDs := parseLayananIDs(r) if len(layananIDs) == 0 { diff --git a/internal/http/layanan.go b/internal/http/layanan.go new file mode 100644 index 0000000..0079292 --- /dev/null +++ b/internal/http/layanan.go @@ -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 +} diff --git a/internal/http/layanan_test.go b/internal/http/layanan_test.go new file mode 100644 index 0000000..df2b9f6 --- /dev/null +++ b/internal/http/layanan_test.go @@ -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()) + } +} diff --git a/internal/http/middleware.go b/internal/http/middleware.go index 93c32a7..6099930 100644 --- a/internal/http/middleware.go +++ b/internal/http/middleware.go @@ -13,7 +13,6 @@ import ( type selectedRepositoryKey struct{} type DatabaseResolver interface { - DefaultCode() string 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")) } 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) diff --git a/internal/http/middleware_database_test.go b/internal/http/middleware_database_test.go index 653588e..9f5d447 100644 --- a/internal/http/middleware_database_test.go +++ b/internal/http/middleware_database_test.go @@ -12,28 +12,25 @@ import ( ) type fakeDatabaseResolver struct { - defaultCode string resolvedCode string err error } -func (f *fakeDatabaseResolver) DefaultCode() string { return f.defaultCode } func (f *fakeDatabaseResolver) Resolve(_ context.Context, code string) (repository.MySQLLayananRepository, error) { f.resolvedCode = code return repository.MySQLLayananRepository{}, f.err } -func TestSelectDatabasePriorityAndDefault(t *testing.T) { +func TestSelectDatabasePriority(t *testing.T) { tests := []struct { name, target, header, want string }{ {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: "default", target: "/resource", want: "AWALBROS_DEV"}, } for _, test := range tests { 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) { if _, ok := selectedRepository(r.Context()); !ok { 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) { - resolver := &fakeDatabaseResolver{defaultCode: "DEFAULT", err: databaseconfig.ErrSettingNotFound} + resolver := &fakeDatabaseResolver{err: databaseconfig.ErrSettingNotFound} request := httptest.NewRequest(http.MethodGet, "/resource?kode_rs=unknown", nil) recorder := httptest.NewRecorder() SelectDatabase(resolver, http.HandlerFunc(func(http.ResponseWriter, *http.Request) { diff --git a/internal/http/paket.go b/internal/http/paket.go index 9c70b64..46387a2 100644 --- a/internal/http/paket.go +++ b/internal/http/paket.go @@ -171,7 +171,12 @@ func currentRequestURL(r *http.Request, page int) string { if host == "" { 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) { diff --git a/internal/http/perusahaan.go b/internal/http/perusahaan.go new file mode 100644 index 0000000..d988b95 --- /dev/null +++ b/internal/http/perusahaan.go @@ -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}) +} diff --git a/internal/http/perusahaan_test.go b/internal/http/perusahaan_test.go new file mode 100644 index 0000000..c0c7047 --- /dev/null +++ b/internal/http/perusahaan_test.go @@ -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()) + } + }) + } +} diff --git a/internal/repository/layanan.go b/internal/repository/layanan.go new file mode 100644 index 0000000..dd18efb --- /dev/null +++ b/internal/repository/layanan.go @@ -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 +} diff --git a/internal/repository/perusahaan.go b/internal/repository/perusahaan.go new file mode 100644 index 0000000..eb319d5 --- /dev/null +++ b/internal/repository/perusahaan.go @@ -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 +}