feat: require hospital code and add company APIs
This commit is contained in:
11
.env.example
11
.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
|
||||
|
||||
34
README.md
34
README.md
@@ -7,6 +7,16 @@ GET /api/cpone/paket
|
||||
GET /api/cpone/paket/{id}
|
||||
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
|
||||
Authorization: Bearer <your-secure-token>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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,
|
||||
|
||||
17
database/migrations/002_remove_default_hospital.sql
Normal file
17
database/migrations/002_remove_default_hospital.sql
Normal 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;
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
65
internal/http/layanan.go
Normal file
65
internal/http/layanan.go
Normal 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
|
||||
}
|
||||
82
internal/http/layanan_test.go
Normal file
82
internal/http/layanan_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
98
internal/http/perusahaan.go
Normal file
98
internal/http/perusahaan.go
Normal 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})
|
||||
}
|
||||
142
internal/http/perusahaan_test.go
Normal file
142
internal/http/perusahaan_test.go
Normal 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())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
71
internal/repository/layanan.go
Normal file
71
internal/repository/layanan.go
Normal 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
|
||||
}
|
||||
290
internal/repository/perusahaan.go
Normal file
290
internal/repository/perusahaan.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user