Compare commits
24 Commits
d183e321e8
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 20ec75fbcf | |||
| dd62728316 | |||
| d881872206 | |||
| 3b75b52ee4 | |||
| 19fe8c5d94 | |||
| 94c3dd1a70 | |||
| d42d97d066 | |||
| c5a959742b | |||
| aae3c733ae | |||
| 9afd5b792c | |||
| 10fa7a6e38 | |||
| 181d53af45 | |||
| 1d5bc309c0 | |||
| 1ee0cfb7f2 | |||
| 8dd880003d | |||
| a59b2c6443 | |||
| 78c02356c5 | |||
| 5c8786329d | |||
| 3e5cae4287 | |||
| b497012de3 | |||
| 7bd62cc65c | |||
| 4d02fc4dc9 | |||
| 30ddf25012 | |||
| 412e926a65 |
23
.env.example
23
.env.example
@@ -1,10 +1,21 @@
|
||||
APP_HOST=0.0.0.0
|
||||
APP_PORT=8080
|
||||
APP_PORT=8282
|
||||
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=3306
|
||||
DB_DATABASE=primaya
|
||||
DB_USERNAME=root
|
||||
DB_PASSWORD=
|
||||
# Kode instance rumah sakit. Dipakai hanya untuk validasi X-RS-Code.
|
||||
CPONE_RS_CODE=AWALBROS_DEV
|
||||
|
||||
# Departemen HIS yang dipakai endpoint registrasi laboratorium.
|
||||
CPONE_DEPARTEMEN_ID=08_01
|
||||
|
||||
# Database HIS rumah sakit untuk instance ini.
|
||||
CPONE_DB_HOST=127.0.0.1
|
||||
CPONE_DB_PORT=3306
|
||||
CPONE_DB_DATABASE=nama_database_his
|
||||
CPONE_DB_USERNAME=cpone
|
||||
CPONE_DB_PASSWORD=
|
||||
|
||||
DB_MAX_OPEN_CONNS=25
|
||||
DB_MAX_IDLE_CONNS=25
|
||||
DB_CONN_MAX_LIFETIME_MINUTES=5
|
||||
|
||||
CPONE_BEARER_TOKEN=replace-with-a-secure-token
|
||||
|
||||
78
Makefile
Normal file
78
Makefile
Normal file
@@ -0,0 +1,78 @@
|
||||
SHELL := /usr/bin/env bash
|
||||
|
||||
APP_NAME ?= cpone-middleware
|
||||
GOOS ?= linux
|
||||
GOARCH ?= amd64
|
||||
CGO_ENABLED ?= 0
|
||||
BUILD_BINARY ?= dist/$(APP_NAME)-$(GOOS)-$(GOARCH)
|
||||
|
||||
REMOTE_DIR ?= auto
|
||||
REMOTE_BINARY ?=
|
||||
SERVICE ?= $(APP_NAME).service
|
||||
SERVICE_SCOPE ?= auto
|
||||
RESTART_MODE ?= systemd-kill
|
||||
SSH_OPTIONS ?= -o ConnectTimeout=10 -o ServerAliveInterval=5 -o ServerAliveCountMax=2
|
||||
SSH_TIMEOUT ?= 45s
|
||||
TRANSFER_TIMEOUT ?= 120s
|
||||
|
||||
# Supports both `make deploy one@host` and the explicit form
|
||||
# `make deploy DEPLOY_TARGET=one@host`.
|
||||
KNOWN_GOALS := help test vet build check-deploy-target deploy
|
||||
POSITIONAL_TARGET := $(firstword $(filter-out $(KNOWN_GOALS),$(MAKECMDGOALS)))
|
||||
DEPLOY_TARGET ?= $(POSITIONAL_TARGET)
|
||||
|
||||
.DEFAULT_GOAL := help
|
||||
.PHONY: help test vet build check-deploy-target deploy
|
||||
|
||||
help:
|
||||
@printf '%s\n' \
|
||||
'Usage:' \
|
||||
' make deploy one@devcpone.aplikasi.web.id' \
|
||||
' make deploy DEPLOY_TARGET=one@devcpone.aplikasi.web.id' \
|
||||
'' \
|
||||
'Optional overrides:' \
|
||||
' REMOTE_DIR=auto (default: $${HOME}/apps di server)' \
|
||||
' SERVICE=cpone-middleware.service' \
|
||||
' SERVICE_SCOPE=auto|user|system' \
|
||||
' RESTART_MODE=systemd-kill|systemctl' \
|
||||
' SSH_OPTIONS="-p 22 -o ConnectTimeout=10"' \
|
||||
' SSH_TIMEOUT=45s TRANSFER_TIMEOUT=120s'
|
||||
|
||||
test:
|
||||
go test ./...
|
||||
|
||||
vet:
|
||||
go vet ./...
|
||||
|
||||
build: test vet
|
||||
@mkdir -p "$$(dirname "$(BUILD_BINARY)")"
|
||||
CGO_ENABLED=$(CGO_ENABLED) GOOS=$(GOOS) GOARCH=$(GOARCH) \
|
||||
go build -trimpath -ldflags='-s -w' -o "$(BUILD_BINARY)" ./cmd/server
|
||||
@printf 'Built %s\n' "$(BUILD_BINARY)"
|
||||
|
||||
check-deploy-target:
|
||||
@if [[ -z "$(DEPLOY_TARGET)" ]]; then \
|
||||
echo 'Error: target server belum diisi.' >&2; \
|
||||
echo 'Contoh: make deploy one@devcpone.aplikasi.web.id' >&2; \
|
||||
exit 2; \
|
||||
fi
|
||||
|
||||
deploy: check-deploy-target build
|
||||
@APP_NAME="$(APP_NAME)" \
|
||||
BUILD_BINARY="$(BUILD_BINARY)" \
|
||||
REMOTE_DIR="$(REMOTE_DIR)" \
|
||||
REMOTE_BINARY="$(REMOTE_BINARY)" \
|
||||
SERVICE="$(SERVICE)" \
|
||||
SERVICE_SCOPE="$(SERVICE_SCOPE)" \
|
||||
RESTART_MODE="$(RESTART_MODE)" \
|
||||
SSH_OPTIONS="$(SSH_OPTIONS)" \
|
||||
SSH_TIMEOUT="$(SSH_TIMEOUT)" \
|
||||
TRANSFER_TIMEOUT="$(TRANSFER_TIMEOUT)" \
|
||||
./scripts/deploy.sh "$(DEPLOY_TARGET)"
|
||||
|
||||
# Extra command-line words are treated as the positional deploy target.
|
||||
ifneq ($(strip $(POSITIONAL_TARGET)),)
|
||||
.PHONY: $(POSITIONAL_TARGET)
|
||||
$(POSITIONAL_TARGET):
|
||||
@:
|
||||
endif
|
||||
243
README.md
243
README.md
@@ -1,5 +1,47 @@
|
||||
# CPONE Middleware Go
|
||||
|
||||
## Deploy ke server
|
||||
|
||||
Tool deploy melakukan test, vet, build binary `linux/amd64`, upload melalui
|
||||
`scp`, mengganti binary secara atomik, membuat backup binary sebelumnya, lalu
|
||||
me-restart service melalui systemd.
|
||||
|
||||
Untuk server development yang sudah dikonfigurasi:
|
||||
|
||||
```bash
|
||||
make deploy one@devcpone.aplikasi.web.id
|
||||
```
|
||||
|
||||
Bentuk eksplisit yang setara:
|
||||
|
||||
```bash
|
||||
make deploy DEPLOY_TARGET=one@devcpone.aplikasi.web.id
|
||||
```
|
||||
|
||||
Default deploy menggunakan:
|
||||
|
||||
- direktori aplikasi: diambil dari `WorkingDirectory` service (otomatis), atau
|
||||
`$HOME/apps` bila service tidak mendefinisikannya
|
||||
- binary: `<direktori aplikasi>/cpone-middleware`
|
||||
- service: `cpone-middleware.service`
|
||||
- service manager: otomatis mendeteksi systemd user (`systemctl --user`) atau
|
||||
systemd system (`systemctl`)
|
||||
- restart: menghentikan `MainPID`, lalu systemd `Restart=always` menyalakan
|
||||
binary baru
|
||||
- SSH memiliki timeout koneksi dan keepalive agar deploy tidak menggantung
|
||||
|
||||
Jika server memakai izin systemd untuk `restart`, gunakan:
|
||||
|
||||
```bash
|
||||
make deploy one@host.example SERVICE=cpone-middleware.service RESTART_MODE=systemctl
|
||||
```
|
||||
|
||||
Untuk direktori khusus, gunakan `REMOTE_DIR=/path/apps`. Untuk target lama
|
||||
`one@devcpone.aplikasi.web.id`, hasil default tetap `/home/one/apps`.
|
||||
|
||||
Target server harus sudah dapat diakses dengan SSH key/agent; tool tidak
|
||||
menyimpan atau mengirim `.env` lokal.
|
||||
|
||||
Service Go untuk endpoint:
|
||||
|
||||
```text
|
||||
@@ -7,15 +49,56 @@ GET /api/cpone/paket
|
||||
GET /api/cpone/paket/{id}
|
||||
Authorization: Bearer <your-secure-token>
|
||||
|
||||
POST /api/cpone/paket
|
||||
Authorization: Bearer <your-secure-token>
|
||||
Content-Type: application/json
|
||||
|
||||
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/doctors?search=&DokterID_in=&page=1&per_page=15
|
||||
GET /api/doctors?search=&DokterID_in=&page=1&per_page=15
|
||||
Authorization: Bearer <your-secure-token>
|
||||
X-RS-Code: <kode-rs>
|
||||
|
||||
Endpoint ini menampilkan daftar dokter aktif dari HIS. Parameter `search` hanya
|
||||
mencari berdasarkan nama dokter atau kode dokter (`DokterID`).
|
||||
|
||||
GET /api/cpone/tarif-kerjasama?search=&perusahaan_id=&tarif_id=&page=1&per_page=15
|
||||
Authorization: Bearer <your-secure-token>
|
||||
|
||||
GET /api/cpone/tarif-kerjasama/{id}?group_tarif_id=&active=N&effective_date=2026-08-22
|
||||
Authorization: Bearer <your-secure-token>
|
||||
|
||||
GET /api/cpone/periode-tarif
|
||||
Authorization: Bearer <your-secure-token>
|
||||
X-RS-Code: <kode-rs>
|
||||
|
||||
Endpoint ini menampilkan seluruh periode tarif dari HIS dengan `NA = 'N'`.
|
||||
|
||||
GET /api/cpone/periode-tarif/{PeriodeTarifID}?search=&page=1
|
||||
Authorization: Bearer <your-secure-token>
|
||||
X-RS-Code: <kode-rs>
|
||||
|
||||
Endpoint ini mengambil detail seluruh tarif layanan dari tabel dinamis
|
||||
`harga_layanan_{PeriodeTarifID}` beserta layanan, departemen, dan kelas tarif.
|
||||
Parameter `search` mencari berdasarkan `LayananID` atau `NamaLayanan`; hasil
|
||||
dipaginasi 20 data per halaman.
|
||||
|
||||
GET /api/cpone/layanan-mapping-harga?periodeTarifID=10&layananID=6413,6414,1309
|
||||
Authorization: Bearer <your-secure-token>
|
||||
|
||||
GET /api/cpone/reports/mcu?tanggal=2026-09-01&tanggal2=2026-09-07
|
||||
Authorization: Bearer <your-secure-token>
|
||||
X-RS-Code: <kode-rs>
|
||||
|
||||
POST /api/cpone/patients/lab-registration
|
||||
Authorization: Bearer <your-secure-token>
|
||||
Content-Type: application/json
|
||||
@@ -24,6 +107,10 @@ POST /api/cpone/patients/lab-registration/{regId}/services
|
||||
Authorization: Bearer <your-secure-token>
|
||||
Content-Type: application/json
|
||||
|
||||
POST /api/cpone/patients/lab-registration/{regId}/packages
|
||||
Authorization: Bearer <your-secure-token>
|
||||
Content-Type: application/json
|
||||
|
||||
POST /api/cpone/patients/medrec
|
||||
Authorization: Bearer <your-secure-token>
|
||||
Content-Type: application/json
|
||||
@@ -31,11 +118,72 @@ Content-Type: application/json
|
||||
|
||||
Format payload dan response dibuat mengikuti controller PHP CPONE terkait.
|
||||
|
||||
Endpoint laporan MCU mengembalikan JSON dengan envelope standar `success`,
|
||||
`message`, dan `data`. Setiap item `data` mempertahankan kolom yang ditulis oleh
|
||||
`docs/lap_mcu.php`, termasuk nilai fallback seperti `Tidak Ada`, `Normal`, dan
|
||||
`UMUM`. Alias kompatibel yang tersedia adalah `/api/cpone/laporan/mcu` dan
|
||||
`/api/cpone/laporan-mcu` serta `/api/cpone/lap-mcu`.
|
||||
|
||||
Query laporan membutuhkan privilege `SELECT` pada tabel MCU legacy:
|
||||
`pengkajianmcu`, `tandavital_pasien`, `regpas_detail`, `gayahidup`,
|
||||
`riwayatpenyakitdahulu`, `riwayatpenyakitkeluarga`, `riwayatalergi`, seluruh
|
||||
tabel `pemeriksaanfisik_*` yang dipakai laporan, `pemeriksaankhusus`, dan
|
||||
`penyakitakibatkerja`.
|
||||
|
||||
Contoh payload pembuatan paket beserta layanan:
|
||||
|
||||
```json
|
||||
{
|
||||
"nama": "PAKET MCU CPONE TES 2",
|
||||
"jenis_paket": "MCU",
|
||||
"rawat_inap": false,
|
||||
"rawat_jalan": true,
|
||||
"multi_visit": false,
|
||||
"tanggal_mulai": "2026-09-01",
|
||||
"tanggal_selesai": "2026-09-30",
|
||||
"kelas_id": "Default",
|
||||
"header_paket_id": "",
|
||||
"keterangan": "",
|
||||
"layanan": [
|
||||
{
|
||||
"layanan_id": "1200",
|
||||
"departemen_id": "MED-PNJ-LAB",
|
||||
"jumlah": 1,
|
||||
"jumlah_hari": 1,
|
||||
"harga": "130000.00",
|
||||
"tipe_diskon": "Percentage",
|
||||
"diskon": "0.00",
|
||||
"tipe_share": "Absolute",
|
||||
"share_rs": "65000.00",
|
||||
"share_dokter": "0.00",
|
||||
"share_external": "65000.00",
|
||||
"share_lain": "0.00"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Endpoint mengembalikan HTTP 201 dengan format standar middleware:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"status": "created",
|
||||
"message": "Paket berhasil dibuat",
|
||||
"data": {
|
||||
"paket_id": "DISP-260900022",
|
||||
"nama": "PAKET MCU CPONE TES 2",
|
||||
"total_harga": "130000.00",
|
||||
"total_diskon": "0.00",
|
||||
"jumlah_layanan": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Contoh payload pembuatan pasien HIS:
|
||||
|
||||
```json
|
||||
{
|
||||
"rs": "PRIMAYA",
|
||||
"nama": "BUDI SANTOS",
|
||||
"jenis_kelamin": "L",
|
||||
"tgl_lahir": "1990-01-15",
|
||||
@@ -51,6 +199,10 @@ Contoh payload pembuatan pasien HIS:
|
||||
"kecamatan": "KECAMATAN CONTOH",
|
||||
"kelurahan": "KELURAHAN CONTOH",
|
||||
"kode_pos": "12345",
|
||||
"perusahaan_id": "PE260700001",
|
||||
"nik_karyawan": "TSB2026002",
|
||||
"dependant_id": "D001",
|
||||
"nama_karyawan": "BUDI SANTOS",
|
||||
"channel": "CPONE"
|
||||
}
|
||||
```
|
||||
@@ -58,6 +210,40 @@ Contoh payload pembuatan pasien HIS:
|
||||
Endpoint mengembalikan `created`, `already_exists`, atau `possible_duplicate`
|
||||
sesuai hasil pemeriksaan identitas dan kemiripan data pasien di HIS.
|
||||
|
||||
Registrasi pasien umum/MCU menggunakan endpoint berikut. Jika `medrec_id` belum
|
||||
diisi dan pasien tidak ditemukan, caller dapat membuat pasien melalui endpoint
|
||||
medrec lalu mengulangi request registrasi. `departemen_id` dapat dikosongkan;
|
||||
middleware akan memakai `CPONE_DEPARTEMEN_ID` dari `.env`.
|
||||
|
||||
```http
|
||||
POST /api/cpone/patients/registration
|
||||
Authorization: Bearer <your-secure-token>
|
||||
X-RS-Code: <kode-rs>
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"medrec_id": "00001234",
|
||||
"nik": "317xxxxxxxxxxxx",
|
||||
"tgl_lahir": "1988-03-03",
|
||||
"tanggal": "2026-09-07",
|
||||
"dokter_id": "D260900003",
|
||||
"jenis_pasien_id": "1",
|
||||
"jenis_reg_id": 2,
|
||||
"periode_tarif_id": "10",
|
||||
"perusahaan_id": "",
|
||||
"asuransi_id": "",
|
||||
"group_tarif_id": "0",
|
||||
"nomer_polis": "",
|
||||
"kelas_id": "Default",
|
||||
"channel": "CPONE"
|
||||
}
|
||||
```
|
||||
|
||||
Endpoint ini idempoten untuk pasien, tanggal, departemen, dan dokter yang sama;
|
||||
registrasi aktif yang sudah ada dikembalikan dengan status `already_registered_visit`.
|
||||
|
||||
Contoh payload registrasi laboratorium:
|
||||
|
||||
```json
|
||||
@@ -103,21 +289,64 @@ Harga, diskon publik, share, unit cost, layanan multiple, dan spesimen diambil
|
||||
langsung dari master HIS. Layanan yang sudah ada akan dilewati dan dilaporkan
|
||||
melalui `skipped_layanan_ids`.
|
||||
|
||||
Contoh payload penambahan Paket Dispenser HIS:
|
||||
|
||||
```json
|
||||
{
|
||||
"paket_id": "DISP-260800011",
|
||||
"cito": false,
|
||||
"markup_cito": 0,
|
||||
"catatan": "",
|
||||
"channel": "CPONE"
|
||||
}
|
||||
```
|
||||
|
||||
Paket harus aktif, disetujui, berlaku untuk rawat jalan, dan belum memiliki
|
||||
layanan yang sama pada registrasi. Request ulang untuk paket yang sudah masuk
|
||||
bersifat idempoten dan tidak membuat transaksi ganda.
|
||||
|
||||
## Config
|
||||
|
||||
Copy `.env.example` menjadi `.env`, lalu isi koneksi MySQL:
|
||||
Setiap instalasi CPONE hanya terhubung ke satu database HIS. Copy `.env.example`
|
||||
menjadi `.env`, lalu isi kode instance dan koneksi MySQL HIS:
|
||||
|
||||
```text
|
||||
APP_HOST=0.0.0.0
|
||||
APP_PORT=8080
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=3306
|
||||
DB_DATABASE=primaya
|
||||
DB_USERNAME=root
|
||||
DB_PASSWORD=
|
||||
CPONE_RS_CODE=AWALBROS_DEV
|
||||
CPONE_DEPARTEMEN_ID=08_01
|
||||
CPONE_DB_HOST=127.0.0.1
|
||||
CPONE_DB_PORT=3306
|
||||
CPONE_DB_DATABASE=nama_database_his
|
||||
CPONE_DB_USERNAME=cpone
|
||||
CPONE_DB_PASSWORD=
|
||||
CPONE_BEARER_TOKEN=replace-with-a-secure-token
|
||||
```
|
||||
|
||||
`CPONE_DB_*` adalah koneksi database HIS, bukan database management CPONE.
|
||||
Password hanya dibaca dari `.env`; aplikasi tidak menyimpan kredensial database
|
||||
ke database lain.
|
||||
|
||||
`CPONE_DEPARTEMEN_ID` adalah `DepartemenID` HIS yang digunakan untuk registrasi
|
||||
laboratorium. Nilai ini diambil dari konfigurasi, sehingga tidak perlu dikirim
|
||||
di payload endpoint registrasi.
|
||||
|
||||
Semua endpoint bisnis tetap membutuhkan header:
|
||||
|
||||
```http
|
||||
X-RS-Code: PRIMAYA_BEKASI_BARAT
|
||||
```
|
||||
|
||||
Sebagai alternatif dapat memakai query `?kode_rs=AWALBROS_DEV`. Header memiliki
|
||||
prioritas lebih tinggi daripada query. Kode tersebut hanya divalidasi terhadap
|
||||
`CPONE_RS_CODE`; kode tidak digunakan untuk memilih database.
|
||||
|
||||
Request tanpa kode mengembalikan `422`, sedangkan kode untuk instance rumah sakit
|
||||
lain mengembalikan `403`. Response sukses tetap mengembalikan header `X-RS-Code`.
|
||||
|
||||
Payload `POST /api/cpone/patients/medrec` tidak lagi membutuhkan field `rs`.
|
||||
Nilai `KodeRS` untuk kebutuhan internal HIS diisi dari `CPONE_RS_CODE`.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
|
||||
@@ -2,7 +2,6 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
@@ -11,11 +10,9 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
|
||||
"primaya-api/cpone-middleware/internal/config"
|
||||
"primaya-api/cpone-middleware/internal/databaseconfig"
|
||||
apphttp "primaya-api/cpone-middleware/internal/http"
|
||||
"primaya-api/cpone-middleware/internal/repository"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -24,35 +21,53 @@ func main() {
|
||||
log.Fatalf("load config: %v", err)
|
||||
}
|
||||
|
||||
db, err := sql.Open("mysql", cfg.MySQLDSN())
|
||||
bootstrapCtx, bootstrapCancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
resolver, err := databaseconfig.NewStaticResolver(bootstrapCtx, cfg.RSCode, databaseconfig.Setting{
|
||||
Host: cfg.HISDBHost, Port: cfg.HISDBPort, Database: cfg.HISDBDatabase,
|
||||
Username: cfg.HISDBUsername, Password: cfg.HISDBPassword,
|
||||
}, databaseconfig.PoolConfig{
|
||||
MaxOpenConns: cfg.DBMaxOpenConns, MaxIdleConns: cfg.DBMaxIdleConns,
|
||||
ConnMaxLifetime: cfg.DBConnMaxLifetime,
|
||||
})
|
||||
bootstrapCancel()
|
||||
if err != nil {
|
||||
log.Fatalf("open mysql: %v", err)
|
||||
log.Fatalf("load database HIS: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
defer resolver.Close()
|
||||
|
||||
db.SetMaxOpenConns(cfg.DBMaxOpenConns)
|
||||
db.SetMaxIdleConns(cfg.DBMaxIdleConns)
|
||||
db.SetConnMaxLifetime(cfg.DBConnMaxLifetime)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := db.PingContext(ctx); err != nil {
|
||||
log.Fatalf("ping mysql: %v", err)
|
||||
handler := apphttp.NewDynamicHandler(cfg.RSCode, cfg.DepartmentID)
|
||||
protected := func(handler http.Handler) http.Handler {
|
||||
return apphttp.BearerAuth(cfg.BearerToken, apphttp.SelectDatabase(resolver, handler))
|
||||
}
|
||||
|
||||
repo := repository.NewMySQLLayananRepository(db)
|
||||
handler := apphttp.NewHandler(repo)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("GET /api/cpone/paket", apphttp.BearerAuth(cfg.BearerToken, http.HandlerFunc(handler.ListPaket)))
|
||||
mux.Handle("GET /api/cpone/paket/{id}", apphttp.BearerAuth(cfg.BearerToken, http.HandlerFunc(handler.GetPaket)))
|
||||
mux.Handle("GET /api/cpone/tarif-kerjasama", apphttp.BearerAuth(cfg.BearerToken, http.HandlerFunc(handler.ListTarifKerjasama)))
|
||||
mux.Handle("GET /api/cpone/tarif-kerjasama/{id}", apphttp.BearerAuth(cfg.BearerToken, http.HandlerFunc(handler.GetTarifKerjasama)))
|
||||
mux.Handle("GET /api/cpone/layanan-mapping-harga", apphttp.BearerAuth(cfg.BearerToken, http.HandlerFunc(handler.MappingHarga)))
|
||||
mux.Handle("POST /api/cpone/patients/medrec", apphttp.BearerAuth(cfg.BearerToken, http.HandlerFunc(handler.StoreMedrec)))
|
||||
mux.Handle("POST /api/cpone/patients/lab-registration", apphttp.BearerAuth(cfg.BearerToken, http.HandlerFunc(handler.RegisterLab)))
|
||||
mux.Handle("POST /api/cpone/patients/lab-registration/{regId}/services", apphttp.BearerAuth(cfg.BearerToken, http.HandlerFunc(handler.AddLabServices)))
|
||||
mux.Handle("GET /api/cpone/patients/lab-results/{labNumber}", apphttp.BearerAuth(cfg.BearerToken, http.HandlerFunc(handler.GetLabResult)))
|
||||
mux.Handle("POST /api/cpone/paket", protected(http.HandlerFunc(handler.CreatePaket)))
|
||||
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/doctors", protected(http.HandlerFunc(handler.ListDokter)))
|
||||
// Backward-compatible route matching the source Primaya API.
|
||||
mux.Handle("GET /api/doctors", protected(http.HandlerFunc(handler.ListDokter)))
|
||||
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/periode-tarif", protected(http.HandlerFunc(handler.ListPeriodeTarif)))
|
||||
mux.Handle("GET /api/cpone/periode-tarif/{periodeTarifID}", protected(http.HandlerFunc(handler.ListPeriodeTarifLayanan)))
|
||||
mux.Handle("GET /api/cpone/layanan-mapping-harga", protected(http.HandlerFunc(handler.MappingHarga)))
|
||||
mux.Handle("POST /api/cpone/patients/medrec", protected(http.HandlerFunc(handler.StoreMedrec)))
|
||||
mux.Handle("POST /api/cpone/patients/registration", protected(http.HandlerFunc(handler.RegisterPatient)))
|
||||
mux.Handle("POST /api/cpone/patients/lab-registration", protected(http.HandlerFunc(handler.RegisterLab)))
|
||||
mux.Handle("POST /api/cpone/patients/lab-registration/{regId}/services", protected(http.HandlerFunc(handler.AddLabServices)))
|
||||
mux.Handle("POST /api/cpone/patients/lab-registration/{regId}/packages", protected(http.HandlerFunc(handler.AddLabPackage)))
|
||||
mux.Handle("GET /api/cpone/patients/lab-results/{labNumber}", protected(http.HandlerFunc(handler.GetLabResult)))
|
||||
// MCU report parity with docs/lap_mcu.php. The additional aliases keep the
|
||||
// report reachable for clients that use the Indonesian legacy route name.
|
||||
mux.Handle("GET /api/cpone/reports/mcu", protected(http.HandlerFunc(handler.ListLaporanMCU)))
|
||||
mux.Handle("GET /api/cpone/laporan/mcu", protected(http.HandlerFunc(handler.ListLaporanMCU)))
|
||||
mux.Handle("GET /api/cpone/laporan-mcu", protected(http.HandlerFunc(handler.ListLaporanMCU)))
|
||||
mux.Handle("GET /api/cpone/lap-mcu", protected(http.HandlerFunc(handler.ListLaporanMCU)))
|
||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
32
database/migrations/001_create_cpone_management.sql
Normal file
32
database/migrations/001_create_cpone_management.sql
Normal file
@@ -0,0 +1,32 @@
|
||||
CREATE DATABASE IF NOT EXISTS `cpone_middleware`
|
||||
CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
USE `cpone_middleware`;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS hospitals (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
code VARCHAR(50) NOT NULL,
|
||||
name VARCHAR(150) NOT NULL,
|
||||
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)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS hospital_databases (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
hospital_id BIGINT UNSIGNED NOT NULL,
|
||||
host VARCHAR(255) NOT NULL,
|
||||
port SMALLINT UNSIGNED NOT NULL DEFAULT 3306,
|
||||
database_name VARCHAR(100) NOT NULL,
|
||||
username VARCHAR(100) NOT NULL,
|
||||
encrypted_password TEXT NOT NULL,
|
||||
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_hospital_databases_hospital (hospital_id),
|
||||
CONSTRAINT fk_hospital_databases_hospital
|
||||
FOREIGN KEY (hospital_id) REFERENCES hospitals(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
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;
|
||||
BIN
docs/akses-tabel-cpone-middleware.docx
Normal file
BIN
docs/akses-tabel-cpone-middleware.docx
Normal file
Binary file not shown.
133
docs/akses-tabel-cpone-middleware.html
Normal file
133
docs/akses-tabel-cpone-middleware.html
Normal file
@@ -0,0 +1,133 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Daftar Akses Tabel CPONE Middleware</title>
|
||||
<style>
|
||||
@page { size: Letter; margin: 1in; }
|
||||
body { font-family: Calibri, Arial, sans-serif; color: #1f2937; font-size: 10.5pt; line-height: 1.25; margin: 0; }
|
||||
h1 { color: #0b2545; font-size: 24pt; line-height: 1.05; margin: 0 0 6pt 0; }
|
||||
h2 { color: #1f4d78; font-size: 15pt; margin: 18pt 0 7pt 0; border-bottom: 1px solid #cbd5e1; padding-bottom: 3pt; }
|
||||
h3 { color: #1f4d78; font-size: 12pt; margin: 12pt 0 5pt 0; }
|
||||
p { margin: 0 0 6pt 0; }
|
||||
.kicker { color: #2e74b5; text-transform: uppercase; letter-spacing: 1.2pt; font-size: 9pt; font-weight: bold; margin-bottom: 7pt; }
|
||||
.subtitle { color: #475569; font-size: 12pt; margin-bottom: 14pt; }
|
||||
.meta { width: 100%; border-collapse: collapse; margin: 0 0 12pt 0; }
|
||||
.meta td { padding: 3pt 8pt 3pt 0; vertical-align: top; border: none; }
|
||||
.meta .label { width: 1.25in; color: #475569; font-weight: bold; }
|
||||
.callout { border-left: 4px solid #2e74b5; background: #f4f6f9; padding: 8pt 10pt; margin: 8pt 0 12pt 0; }
|
||||
.callout strong { color: #0b2545; }
|
||||
table.data { width: 100%; border-collapse: collapse; table-layout: fixed; margin: 6pt 0 10pt 0; }
|
||||
table.data th { background: #e8eef5; color: #0b2545; font-weight: bold; text-align: left; padding: 5pt 7pt; border: 1px solid #aebdcd; }
|
||||
table.data td { padding: 4pt 7pt; border: 1px solid #cbd5e1; vertical-align: top; }
|
||||
table.data tr { page-break-inside: avoid; }
|
||||
table.data th:nth-child(1) { width: 38%; }
|
||||
table.data th:nth-child(2) { width: 14%; text-align: center; }
|
||||
table.data th:nth-child(3) { width: 20%; text-align: center; }
|
||||
table.data th:nth-child(4) { width: 28%; }
|
||||
table.data td:nth-child(2), table.data td:nth-child(3) { text-align: center; }
|
||||
table.compact th:first-child, table.compact td:first-child { width: 42%; }
|
||||
table.compact th:last-child, table.compact td:last-child { width: 58%; }
|
||||
.mono { font-family: Consolas, "Courier New", monospace; font-size: 9.2pt; color: #111827; }
|
||||
.small { font-size: 9pt; color: #475569; }
|
||||
.pill { font-weight: bold; color: #1f3a5f; }
|
||||
pre { font-family: Consolas, "Courier New", monospace; font-size: 8.6pt; line-height: 1.2; background: #f8fafc; border: 1px solid #cbd5e1; padding: 8pt; white-space: pre-wrap; margin: 5pt 0 10pt 0; }
|
||||
ul { margin: 4pt 0 8pt 18pt; padding: 0; }
|
||||
li { margin: 0 0 3pt 0; }
|
||||
.footer { color: #64748b; font-size: 8.5pt; border-top: 1px solid #cbd5e1; padding-top: 5pt; margin-top: 18pt; }
|
||||
.pagebreak { page-break-before: always; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="kicker">CPONE Middleware • Database Access Reference</div>
|
||||
<h1>Daftar Akses Tabel</h1>
|
||||
<div class="subtitle">Kebutuhan privilege database untuk service CPONE Middleware</div>
|
||||
|
||||
<table class="meta">
|
||||
<tr><td class="label">Database target</td><td>Database HIS yang dikonfigurasi pada <span class="mono">CPONE_DB_DATABASE</span></td></tr>
|
||||
<tr><td class="label">Database user</td><td>User yang dikonfigurasi pada <span class="mono">CPONE_DB_USERNAME</span></td></tr>
|
||||
<tr><td class="label">Dasar pemetaan</td><td>Query SQL pada folder <span class="mono">internal/repository</span></td></tr>
|
||||
<tr><td class="label">Tanggal</td><td>17 September 2026</td></tr>
|
||||
</table>
|
||||
|
||||
<div class="callout"><strong>Ringkasan:</strong> service membutuhkan akses <strong>SELECT</strong> pada tabel master dan tabel transaksi yang dibaca. Akses write diperlukan hanya pada tabel yang dipakai endpoint POST untuk membuat paket, pasien, registrasi, layanan laboratorium, nominal, dan diagnosis.</div>
|
||||
|
||||
<h2>1. Tabel dengan READ dan WRITE</h2>
|
||||
<p>Tabel berikut membutuhkan akses <span class="pill">SELECT</span> dan operasi write seperti tercantum.</p>
|
||||
<table class="data">
|
||||
<tr><th>Nama tabel</th><th>READ</th><th>WRITE</th><th>Kegunaan utama</th></tr>
|
||||
<tr><td class="mono">medrec</td><td>SELECT</td><td>INSERT</td><td>Pencarian dan pembuatan data pasien/medical record.</td></tr>
|
||||
<tr><td class="mono">regpas</td><td>SELECT</td><td>INSERT</td><td>Pembuatan dan pengecekan registrasi pasien.</td></tr>
|
||||
<tr><td class="mono">regpas_nominal</td><td>SELECT</td><td>INSERT, UPDATE</td><td>Nominal administrasi pada level registrasi pasien.</td></tr>
|
||||
<tr><td class="mono">trxdepartemen</td><td>SELECT</td><td>INSERT</td><td>Pembuatan transaksi departemen.</td></tr>
|
||||
<tr><td class="mono">trxdepartemen_nominal</td><td>SELECT</td><td>INSERT, UPDATE</td><td>Rekap nominal layanan/item/tempat tidur dan total transaksi.</td></tr>
|
||||
<tr><td class="mono">trxidlaboratorium</td><td>SELECT</td><td>INSERT</td><td>Pembuatan nomor/transaksi laboratorium.</td></tr>
|
||||
<tr><td class="mono">trxlayanan</td><td>SELECT</td><td>INSERT</td><td>Penambahan layanan pada transaksi.</td></tr>
|
||||
<tr><td class="mono">trxlayanan_detail</td><td>SELECT</td><td>INSERT</td><td>Relasi detail layanan dengan paket dispenser.</td></tr>
|
||||
<tr><td class="mono">trxlayanan_penunjang</td><td>SELECT</td><td>INSERT</td><td>Relasi layanan penunjang/header laboratorium.</td></tr>
|
||||
<tr><td class="mono">lab_trxspesimen</td><td>SELECT</td><td>INSERT</td><td>Pencatatan spesimen laboratorium.</td></tr>
|
||||
<tr><td class="mono">paket_header</td><td>SELECT</td><td>INSERT, UPDATE, DELETE</td><td>Pembuatan paket; DELETE hanya untuk cleanup saat transaksi gagal.</td></tr>
|
||||
<tr><td class="mono">paket_layanan</td><td>SELECT</td><td>INSERT, DELETE</td><td>Detail layanan paket; DELETE hanya untuk cleanup.</td></tr>
|
||||
<tr><td class="mono">paket_departemen</td><td>SELECT</td><td>INSERT, DELETE</td><td>Departemen yang terkait dengan paket; DELETE hanya untuk cleanup.</td></tr>
|
||||
<tr><td class="mono">paket_dispenser_header</td><td>SELECT</td><td>INSERT</td><td>Header paket dispenser pada kunjungan pasien.</td></tr>
|
||||
<tr><td class="mono">paket_dispenser_episode_rj</td><td>SELECT</td><td>INSERT</td><td>Episode kunjungan rawat jalan paket dispenser.</td></tr>
|
||||
<tr><td class="mono">paket_dispenser_layanan</td><td>SELECT</td><td>INSERT</td><td>Layanan yang dihasilkan dari paket dispenser.</td></tr>
|
||||
<tr><td class="mono">paket_dispenser_item</td><td>SELECT</td><td>INSERT</td><td>Item paket dispenser.</td></tr>
|
||||
<tr><td class="mono">paket_dispenser_adm</td><td>SELECT</td><td>INSERT</td><td>Administrasi paket dispenser.</td></tr>
|
||||
</table>
|
||||
|
||||
<h2>2. Tabel dengan WRITE saja</h2>
|
||||
<table class="data compact">
|
||||
<tr><th>Nama tabel</th><th>Operasi</th></tr>
|
||||
<tr><td class="mono">diagnosa_log</td><td>INSERT — menyimpan log diagnosis kerja pada registrasi laboratorium.</td></tr>
|
||||
<tr><td class="mono">regpas_diagnosa</td><td>INSERT — menyimpan diagnosis kerja yang terkait dengan pasien/registrasi.</td></tr>
|
||||
</table>
|
||||
<p class="small">Kedua tabel di atas tidak dibaca kembali oleh service dalam query saat ini, tetapi harus dapat menerima INSERT ketika endpoint registrasi laboratorium menyimpan diagnosis.</p>
|
||||
|
||||
<h2>3. Tabel dengan READ saja</h2>
|
||||
<p>Tabel master dan referensi berikut hanya dibaca oleh service, sehingga cukup diberikan privilege <span class="pill">SELECT</span>.</p>
|
||||
<table class="data compact">
|
||||
<tr><th>Kelompok</th><th>Nama tabel</th></tr>
|
||||
<tr><td>Tarif dan layanan</td><td class="mono">adm_periode, harga_layanan_diskon, layanandepartemen, masterlayanan, jenislayanan, periode_tarif, perusahaan_grouptarif, perusahaan_tarif, perusahaan</td></tr>
|
||||
<tr><td>Master rumah sakit</td><td class="mono">departemen, dokter, rs, kelas, satuan, satuanlab, shiftharian</td></tr>
|
||||
<tr><td>Master paket</td><td class="mono">paket_adm, paket_item, paket_operasi_header, paket_tt, paket_dispenser_operasi_header</td></tr>
|
||||
<tr><td>Master operasi dan referensi</td><td class="mono">golonganoperasi, jenisanestesi, kasusoperasi, mastertindakan, konversisatuan, margin_alkes, margin_obat, sys_setting</td></tr>
|
||||
<tr><td>Master laboratorium</td><td class="mono">grouplayanan, lab_jenisspesimen, lab_masterlayanan, lab_multiple, lab_reference, lab_spesimentest, lab_trxlayanan_setup, trxlayananlaboratorium, trxlayanan_proses</td></tr>
|
||||
<tr><td>Master pasien/registrasi</td><td class="mono">jenispaket, jenispasien, jenisreg</td></tr>
|
||||
<tr><td>Metadata MySQL</td><td class="mono">information_schema.tables, information_schema.columns</td></tr>
|
||||
</table>
|
||||
|
||||
<h2>4. Tabel dinamis</h2>
|
||||
<table class="data compact">
|
||||
<tr><th>Pola nama tabel</th><th>Kebutuhan akses</th></tr>
|
||||
<tr><td class="mono">harga_layanan_{PeriodeTarifID}</td><td>SELECT. Contoh: <span class="mono">harga_layanan_10</span>. Dipakai untuk membaca tarif layanan berdasarkan periode tarif.</td></tr>
|
||||
<tr><td class="mono">perusahaan_tarif_layanan_{GroupTarifID}</td><td>SELECT. Dipakai untuk membaca detail tarif kerja sama berdasarkan group tarif.</td></tr>
|
||||
</table>
|
||||
|
||||
<h2>5. Rekomendasi grant</h2>
|
||||
<p>Untuk koneksi service, berikan privilege secara eksplisit pada database HIS. Gunakan nama database dan host user sesuai lingkungan server.</p>
|
||||
<pre>-- Ganti nama_database_his, cpone, dan host_user sesuai environment.
|
||||
GRANT SELECT ON `nama_database_his`.`adm_periode` TO 'cpone'@'host_user';
|
||||
|
||||
-- Contoh tabel yang membutuhkan write:
|
||||
GRANT SELECT, INSERT ON `nama_database_his`.`medrec` TO 'cpone'@'host_user';
|
||||
GRANT SELECT, INSERT ON `nama_database_his`.`regpas` TO 'cpone'@'host_user';
|
||||
GRANT SELECT, INSERT, UPDATE ON `nama_database_his`.`regpas_nominal` TO 'cpone'@'host_user';
|
||||
GRANT SELECT, INSERT, UPDATE ON `nama_database_his`.`trxdepartemen_nominal` TO 'cpone'@'host_user';
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON `nama_database_his`.`paket_header` TO 'cpone'@'host_user';
|
||||
|
||||
-- Ulangi pola di atas untuk seluruh tabel pada daftar dokumen ini.
|
||||
-- Tabel dinamis memerlukan SELECT pada setiap tabel yang benar-benar digunakan.</pre>
|
||||
|
||||
<h2>6. Catatan penting</h2>
|
||||
<ul>
|
||||
<li>Service hanya membuka koneksi ke database HIS melalui konfigurasi <span class="mono">CPONE_DB_*</span>.</li>
|
||||
<li>Privilege <span class="mono">DELETE</span> pada <span class="mono">paket_header</span>, <span class="mono">paket_layanan</span>, dan <span class="mono">paket_departemen</span> diperlukan untuk cleanup jika proses pembuatan paket gagal sebagian.</li>
|
||||
<li><span class="mono">regpas_nominal</span> dibuat saat registrasi laboratorium untuk inisialisasi nominal administrasi dengan nilai awal nol dan metode <span class="mono">Otomatis</span>.</li>
|
||||
<li>Tabel <span class="mono">hospitals</span> dan <span class="mono">hospital_databases</span> pada migration <span class="mono">cpone_middleware</span> belum digunakan oleh runtime Go service saat ini.</li>
|
||||
<li>Daftar ini dipetakan dari kode aplikasi; validasi final tetap perlu disesuaikan dengan struktur tabel dan policy keamanan database HIS.</li>
|
||||
</ul>
|
||||
|
||||
<div class="footer">Dokumen internal • Sumber pemetaan: repository CPONE Middleware • Versi berdasarkan kode yang tersedia pada 17 September 2026</div>
|
||||
</body>
|
||||
</html>
|
||||
209
docs/build_akses_docx.py
Normal file
209
docs/build_akses_docx.py
Normal file
@@ -0,0 +1,209 @@
|
||||
from zipfile import ZipFile, ZIP_DEFLATED
|
||||
from xml.sax.saxutils import escape
|
||||
from pathlib import Path
|
||||
|
||||
OUT = Path(__file__).with_name("akses-tabel-cpone-middleware.docx")
|
||||
|
||||
W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
|
||||
R = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
|
||||
CT = "http://schemas.openxmlformats.org/package/2006/content-types"
|
||||
PR = "http://schemas.openxmlformats.org/package/2006/relationships"
|
||||
|
||||
def qn(tag):
|
||||
prefix, local = tag.split(":", 1)
|
||||
return f"{{{ {'w': W, 'r': R, 'ct': CT, 'pr': PR}[prefix] }}}{local}"
|
||||
|
||||
def attr(name, value):
|
||||
return f' {name}="{escape(str(value))}"'
|
||||
|
||||
def run(text, bold=False, color=None, size=21, font="Calibri", italic=False):
|
||||
rpr = [f'<w:rFonts w:ascii="{font}" w:hAnsi="{font}"/>', f'<w:sz w:val="{size}"/>', f'<w:szCs w:val="{size}"/>']
|
||||
if bold:
|
||||
rpr.append('<w:b/><w:bCs/>')
|
||||
if italic:
|
||||
rpr.append('<w:i/><w:iCs/>')
|
||||
if color:
|
||||
rpr.append(f'<w:color w:val="{color}"/>')
|
||||
return f'<w:r><w:rPr>{"".join(rpr)}</w:rPr><w:t xml:space="preserve">{escape(text)}</w:t></w:r>'
|
||||
|
||||
def paragraph(text="", style="Normal", before=0, after=120, color=None, bold=False, size=21, align=None, keep=False, page_break=False):
|
||||
ppr = [f'<w:pStyle w:val="{style}"/>', f'<w:spacing w:before="{before}" w:after="{after}" w:line="300" w:lineRule="auto"/>']
|
||||
if align:
|
||||
ppr.append(f'<w:jc w:val="{align}"/>')
|
||||
if keep:
|
||||
ppr.append('<w:keepNext/>')
|
||||
if page_break:
|
||||
ppr.append('<w:pageBreakBefore/>')
|
||||
body = run(text, bold=bold, color=color, size=size) if text else ''
|
||||
return f'<w:p><w:pPr>{"".join(ppr)}</w:pPr>{body}</w:p>'
|
||||
|
||||
def code_paragraph(text, before=80, after=160):
|
||||
ppr = [
|
||||
'<w:pStyle w:val="CodeBlock"/>',
|
||||
f'<w:spacing w:before="{before}" w:after="{after}" w:line="240" w:lineRule="auto"/>',
|
||||
]
|
||||
content = []
|
||||
for index, line in enumerate(text.splitlines()):
|
||||
if index:
|
||||
content.append('<w:br/>')
|
||||
content.append(run(line, size=16, font="Consolas"))
|
||||
return f'<w:p><w:pPr>{"".join(ppr)}</w:pPr>{"".join(content)}</w:p>'
|
||||
|
||||
def rich_paragraph(parts, style="Normal", before=0, after=120, align=None):
|
||||
ppr = [f'<w:pStyle w:val="{style}"/>', f'<w:spacing w:before="{before}" w:after="{after}" w:line="300" w:lineRule="auto"/>']
|
||||
if align:
|
||||
ppr.append(f'<w:jc w:val="{align}"/>')
|
||||
content = ''.join(run(text, bold=bold, color=color, size=size) for text, bold, color, size in parts)
|
||||
return f'<w:p><w:pPr>{"".join(ppr)}</w:pPr>{content}</w:p>'
|
||||
|
||||
def cell(text, width, header=False, align="left"):
|
||||
fill = 'E8EEF5' if header else 'FFFFFF'
|
||||
color = '0B2545' if header else '1F2937'
|
||||
size = 18 if header else 18
|
||||
tcpr = f'<w:tcW w:w="{width}" w:type="dxa"/><w:shd w:fill="{fill}"/><w:vAlign w:val="center"/>'
|
||||
ppr = f'<w:jc w:val="{align}"/><w:spacing w:before="0" w:after="0" w:line="240" w:lineRule="auto"/>'
|
||||
text_xml = run(text, bold=header, color=color, size=size, font="Consolas" if (not header and ("_" in text or "{" in text or "}" in text)) else "Calibri")
|
||||
return f'<w:tc><w:tcPr>{tcpr}</w:tcPr><w:p><w:pPr>{ppr}</w:pPr>{text_xml}</w:p></w:tc>'
|
||||
|
||||
def table(headers, rows, widths, aligns=None):
|
||||
aligns = aligns or ["left"] * len(widths)
|
||||
grid = ''.join(f'<w:gridCol w:w="{w}"/>' for w in widths)
|
||||
borders = '<w:tblBorders><w:top w:val="single" w:sz="6" w:color="AEB DCD"/><w:left w:val="single" w:sz="6" w:color="AEBDCD"/><w:bottom w:val="single" w:sz="6" w:color="AEBDCD"/><w:right w:val="single" w:sz="6" w:color="AEBDCD"/><w:insideH w:val="single" w:sz="4" w:color="CBD5E1"/><w:insideV w:val="single" w:sz="4" w:color="CBD5E1"/></w:tblBorders>'
|
||||
borders = borders.replace("AEB DCD", "AEBDCD")
|
||||
cellmar = '<w:tblCellMar><w:top w:w="80" w:type="dxa"/><w:left w:w="120" w:type="dxa"/><w:bottom w:w="80" w:type="dxa"/><w:right w:w="120" w:type="dxa"/></w:tblCellMar>'
|
||||
tblpr = f'<w:tblW w:w="9360" w:type="dxa"/><w:tblInd w:w="120" w:type="dxa"/><w:tblLayout w:type="fixed"/>{borders}{cellmar}'
|
||||
head = '<w:tr><w:trPr><w:tblHeader/></w:trPr>' + ''.join(cell(h, w, True, aligns[i]) for i, (h, w) in enumerate(zip(headers, widths))) + '</w:tr>'
|
||||
body = ''.join('<w:tr>' + ''.join(cell(str(value), w, False, aligns[i]) for i, (value, w) in enumerate(zip(row, widths))) + '</w:tr>' for row in rows)
|
||||
return f'<w:tbl><w:tblPr>{tblpr}</w:tblPr><w:tblGrid>{grid}</w:tblGrid>{head}{body}</w:tbl>'
|
||||
|
||||
def bullet(text):
|
||||
return '<w:p><w:pPr><w:pStyle w:val="Normal"/><w:numPr><w:ilvl w:val="0"/><w:numId w:val="1"/></w:numPr><w:spacing w:after="80" w:line="300" w:lineRule="auto"/></w:pPr>' + run(text, size=20) + '</w:p>'
|
||||
|
||||
body = []
|
||||
body.append(paragraph("CPONE MIDDLEWARE • DATABASE ACCESS REFERENCE", color="2E74B5", bold=True, size=16, after=100))
|
||||
body.append(paragraph("Daftar Akses Tabel", style="DocTitle", size=48, color="0B2545", after=80))
|
||||
body.append(paragraph("Kebutuhan privilege database untuk service CPONE Middleware", color="475569", size=24, after=220))
|
||||
body.append(table(["Informasi", "Nilai"], [
|
||||
("Database target", "Database HIS pada CPONE_DB_DATABASE"),
|
||||
("Database user", "User pada CPONE_DB_USERNAME"),
|
||||
("Dasar pemetaan", "Query SQL pada folder internal/repository"),
|
||||
("Tanggal", "17 September 2026"),
|
||||
], [1900, 7460], ["left", "left"]))
|
||||
body.append(rich_paragraph([("Ringkasan: ", True, "0B2545", 20), ("service membutuhkan SELECT pada tabel master dan tabel transaksi yang dibaca. Akses write hanya diperlukan pada tabel yang digunakan endpoint POST untuk membuat paket, pasien, registrasi, layanan laboratorium, nominal, dan diagnosis.", False, "1F2937", 20)], after=180))
|
||||
|
||||
body.append(paragraph("1. Tabel dengan READ dan WRITE", style="Heading1", before=260, after=100, keep=True))
|
||||
body.append(paragraph("Tabel berikut membutuhkan SELECT dan operasi write seperti tercantum.", after=100))
|
||||
body.append(table(["Nama tabel", "READ", "WRITE", "Kegunaan utama"], [
|
||||
("medrec", "SELECT", "INSERT", "Pencarian dan pembuatan data pasien/medical record."),
|
||||
("regpas", "SELECT", "INSERT", "Pembuatan dan pengecekan registrasi pasien."),
|
||||
("regpas_nominal", "SELECT", "INSERT, UPDATE", "Nominal administrasi pada level registrasi pasien."),
|
||||
("trxdepartemen", "SELECT", "INSERT", "Pembuatan transaksi departemen."),
|
||||
("trxdepartemen_nominal", "SELECT", "INSERT, UPDATE", "Rekap nominal layanan/item/tempat tidur dan total transaksi."),
|
||||
("trxidlaboratorium", "SELECT", "INSERT", "Pembuatan nomor/transaksi laboratorium."),
|
||||
("trxlayanan", "SELECT", "INSERT", "Penambahan layanan pada transaksi."),
|
||||
("trxlayanan_detail", "SELECT", "INSERT", "Relasi detail layanan dengan paket dispenser."),
|
||||
("trxlayanan_penunjang", "SELECT", "INSERT", "Relasi layanan penunjang/header laboratorium."),
|
||||
("lab_trxspesimen", "SELECT", "INSERT", "Pencatatan spesimen laboratorium."),
|
||||
("paket_header", "SELECT", "INSERT, UPDATE, DELETE", "Pembuatan paket; DELETE hanya untuk cleanup saat transaksi gagal."),
|
||||
("paket_layanan", "SELECT", "INSERT, DELETE", "Detail layanan paket; DELETE hanya untuk cleanup."),
|
||||
("paket_departemen", "SELECT", "INSERT, DELETE", "Departemen terkait paket; DELETE hanya untuk cleanup."),
|
||||
("paket_dispenser_header", "SELECT", "INSERT", "Header paket dispenser pada kunjungan pasien."),
|
||||
("paket_dispenser_episode_rj", "SELECT", "INSERT", "Episode kunjungan rawat jalan paket dispenser."),
|
||||
("paket_dispenser_layanan", "SELECT", "INSERT", "Layanan yang dihasilkan dari paket dispenser."),
|
||||
("paket_dispenser_item", "SELECT", "INSERT", "Item paket dispenser."),
|
||||
("paket_dispenser_adm", "SELECT", "INSERT", "Administrasi paket dispenser."),
|
||||
], [2600, 1000, 1600, 4160], ["left", "center", "center", "left"]))
|
||||
|
||||
body.append(paragraph("2. Tabel dengan WRITE saja", style="Heading1", before=260, after=100, keep=True))
|
||||
body.append(table(["Nama tabel", "Operasi dan kegunaan"], [
|
||||
("diagnosa_log", "INSERT — log diagnosis kerja pada registrasi laboratorium."),
|
||||
("regpas_diagnosa", "INSERT — diagnosis kerja yang terkait dengan pasien/registrasi."),
|
||||
], [2600, 6760], ["left", "left"]))
|
||||
body.append(paragraph("Kedua tabel ini tidak dibaca kembali oleh service dalam query saat ini, tetapi harus dapat menerima INSERT ketika endpoint registrasi laboratorium menyimpan diagnosis.", color="475569", size=18, after=100))
|
||||
|
||||
body.append(paragraph("3. Tabel dengan READ saja", style="Heading1", before=260, after=100, keep=True))
|
||||
body.append(paragraph("Tabel master dan referensi berikut hanya dibaca oleh service, sehingga cukup diberikan privilege SELECT.", after=100))
|
||||
body.append(table(["Kelompok", "Nama tabel"], [
|
||||
("Tarif dan layanan", "adm_periode, harga_layanan_diskon, layanandepartemen, masterlayanan, jenislayanan, periode_tarif, perusahaan_grouptarif, perusahaan_tarif, perusahaan"),
|
||||
("Master rumah sakit", "departemen, dokter, rs, kelas, satuan, satuanlab, shiftharian"),
|
||||
("Master paket", "paket_adm, paket_item, paket_operasi_header, paket_tt, paket_dispenser_operasi_header"),
|
||||
("Master operasi dan referensi", "golonganoperasi, jenisanestesi, kasusoperasi, mastertindakan, konversisatuan, margin_alkes, margin_obat, sys_setting"),
|
||||
("Master laboratorium", "grouplayanan, lab_jenisspesimen, lab_masterlayanan, lab_multiple, lab_reference, lab_spesimentest, lab_trxlayanan_setup, trxlayananlaboratorium, trxlayanan_proses"),
|
||||
("Master pasien/registrasi", "jenispaket, jenispasien, jenisreg"),
|
||||
("Metadata MySQL", "information_schema.tables, information_schema.columns"),
|
||||
], [2500, 6860], ["left", "left"]))
|
||||
|
||||
body.append(paragraph("4. Tabel dinamis", style="Heading1", before=260, after=100, keep=True))
|
||||
body.append(table(["Pola nama tabel", "Kebutuhan akses"], [
|
||||
("harga_layanan_{PeriodeTarifID}", "SELECT. Contoh: harga_layanan_10. Dipakai untuk membaca tarif layanan berdasarkan periode tarif."),
|
||||
("perusahaan_tarif_layanan_{GroupTarifID}", "SELECT. Dipakai untuk membaca detail tarif kerja sama berdasarkan group tarif."),
|
||||
], [3000, 6360], ["left", "left"]))
|
||||
|
||||
body.append(paragraph("5. Rekomendasi grant", style="Heading1", before=260, after=100, keep=True))
|
||||
body.append(paragraph("Berikan privilege secara eksplisit pada database HIS. Ganti nama database, user, dan host sesuai environment server.", after=100))
|
||||
grant_text = """-- Ganti nama_database_his, cpone, dan host_user sesuai environment.
|
||||
GRANT SELECT ON `nama_database_his`.`adm_periode` TO 'cpone'@'host_user';
|
||||
|
||||
-- Contoh tabel yang membutuhkan write:
|
||||
GRANT SELECT, INSERT ON `nama_database_his`.`medrec` TO 'cpone'@'host_user';
|
||||
GRANT SELECT, INSERT ON `nama_database_his`.`regpas` TO 'cpone'@'host_user';
|
||||
GRANT SELECT, INSERT, UPDATE ON `nama_database_his`.`regpas_nominal` TO 'cpone'@'host_user';
|
||||
GRANT SELECT, INSERT, UPDATE ON `nama_database_his`.`trxdepartemen_nominal` TO 'cpone'@'host_user';
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON `nama_database_his`.`paket_header` TO 'cpone'@'host_user';
|
||||
|
||||
-- Ulangi pola di atas untuk seluruh tabel pada daftar ini.
|
||||
-- Tabel dinamis memerlukan SELECT pada setiap tabel yang benar-benar digunakan."""
|
||||
body.append(code_paragraph(grant_text))
|
||||
|
||||
body.append(paragraph("6. Catatan penting", style="Heading1", before=260, after=100, keep=True))
|
||||
for item in [
|
||||
"Service hanya membuka koneksi ke database HIS melalui konfigurasi CPONE_DB_*.",
|
||||
"Privilege DELETE pada paket_header, paket_layanan, dan paket_departemen diperlukan untuk cleanup jika proses pembuatan paket gagal sebagian.",
|
||||
"regpas_nominal dibuat saat registrasi laboratorium untuk inisialisasi nominal administrasi dengan nilai awal nol dan metode Otomatis.",
|
||||
"Tabel hospitals dan hospital_databases pada migration cpone_middleware belum digunakan oleh runtime Go service saat ini.",
|
||||
"Daftar ini dipetakan dari kode aplikasi; validasi final tetap perlu disesuaikan dengan struktur tabel dan policy keamanan database HIS.",
|
||||
]:
|
||||
body.append(bullet(item))
|
||||
|
||||
body.append(paragraph("Dokumen internal • Sumber pemetaan: repository CPONE Middleware • Versi berdasarkan kode yang tersedia pada 17 September 2026", color="64748B", size=16, before=240, after=0))
|
||||
|
||||
document = f'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<w:document xmlns:w="{W}" xmlns:r="{R}"><w:body>{''.join(body)}<w:sectPr><w:headerReference w:type="default" r:id="rId4"/><w:footerReference w:type="default" r:id="rId3"/><w:pgSz w:w="12240" w:h="15840"/><w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440" w:header="708" w:footer="708"/><w:cols w:space="708"/><w:docGrid w:linePitch="360"/></w:sectPr></w:body></w:document>'''
|
||||
|
||||
styles = f'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<w:styles xmlns:w="{W}">
|
||||
<w:docDefaults><w:rPrDefault><w:rPr><w:rFonts w:ascii="Calibri" w:hAnsi="Calibri"/><w:sz w:val="21"/><w:szCs w:val="21"/><w:color w:val="1F2937"/></w:rPr></w:rPrDefault><w:pPrDefault><w:pPr><w:spacing w:after="120" w:line="300" w:lineRule="auto"/></w:pPr></w:pPrDefault></w:docDefaults>
|
||||
<w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/><w:qFormat/><w:rPr><w:rFonts w:ascii="Calibri" w:hAnsi="Calibri"/><w:sz w:val="21"/><w:szCs w:val="21"/><w:color w:val="1F2937"/></w:rPr></w:style>
|
||||
<w:style w:type="paragraph" w:styleId="DocTitle"><w:name w:val="Document Title"/><w:qFormat/><w:pPr><w:spacing w:before="0" w:after="80" w:line="300" w:lineRule="auto"/></w:pPr><w:rPr><w:rFonts w:ascii="Calibri" w:hAnsi="Calibri"/><w:sz w:val="48"/><w:szCs w:val="48"/><w:b/><w:color w:val="0B2545"/></w:rPr></w:style>
|
||||
<w:style w:type="paragraph" w:styleId="Heading1"><w:name w:val="heading 1"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:qFormat/><w:pPr><w:keepNext/><w:spacing w:before="360" w:after="200" w:line="300" w:lineRule="auto"/></w:pPr><w:rPr><w:rFonts w:ascii="Calibri" w:hAnsi="Calibri"/><w:sz w:val="32"/><w:szCs w:val="32"/><w:b/><w:color w:val="2E74B5"/></w:rPr></w:style>
|
||||
<w:style w:type="paragraph" w:styleId="CodeBlock"><w:name w:val="Code Block"/><w:basedOn w:val="Normal"/><w:pPr><w:shd w:fill="F4F6F9"/><w:spacing w:before="80" w:after="160" w:line="240" w:lineRule="auto"/></w:pPr><w:rPr><w:rFonts w:ascii="Consolas" w:hAnsi="Consolas"/><w:sz w:val="16"/><w:szCs w:val="16"/><w:color w:val="111827"/></w:rPr></w:style>
|
||||
<w:style w:type="character" w:styleId="DefaultParagraphFont"><w:name w:val="Default Paragraph Font"/><w:uiPriority w:val="1"/><w:semiHidden/></w:style>
|
||||
</w:styles>'''
|
||||
|
||||
numbering = f'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<w:numbering xmlns:w="{W}"><w:abstractNum w:abstractNumId="0"><w:multiLevelType w:val="hybridMultilevel"/><w:lvl w:ilvl="0"><w:start w:val="1"/><w:numFmt w:val="bullet"/><w:lvlText w:val="•"/><w:lvlJc w:val="left"/><w:pPr><w:tabs><w:tab w:val="num" w:pos="540"/></w:tabs><w:ind w:left="540" w:hanging="270"/></w:pPr><w:rPr><w:rFonts w:ascii="Symbol" w:hAnsi="Symbol"/></w:rPr></w:lvl></w:abstractNum><w:num w:numId="1"><w:abstractNumId w:val="0"/></w:num></w:numbering>'''
|
||||
|
||||
document_rels = f'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="{PR}"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/numbering" Target="numbering.xml"/><Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer" Target="footer1.xml"/><Relationship Id="rId4" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Target="header1.xml"/></Relationships>'''
|
||||
|
||||
footer = f'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:ftr xmlns:w="{W}" xmlns:r="{R}"><w:p><w:pPr><w:jc w:val="right"/><w:spacing w:before="0" w:after="0"/></w:pPr>{run("CPONE Middleware • ", color="64748B", size=16)}<w:fldSimple w:instr="PAGE"><w:r><w:rPr><w:sz w:val="16"/></w:rPr><w:t>1</w:t></w:r></w:fldSimple></w:p></w:ftr>'''
|
||||
header = f'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:hdr xmlns:w="{W}"><w:p><w:pPr><w:spacing w:before="0" w:after="0"/></w:pPr>{run("CPONE Middleware | Daftar Akses Tabel", color="64748B", size=16)}</w:p></w:hdr>'''
|
||||
|
||||
content_types = f'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="{CT}"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/><Override PartName="/word/numbering.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml"/><Override PartName="/word/header1.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml"/><Override PartName="/word/footer1.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml"/><Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/><Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/></Types>'''
|
||||
root_rels = f'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="{PR}"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" Target="docProps/core.xml"/><Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/></Relationships>'''
|
||||
core = f'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?><cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title>Daftar Akses Tabel CPONE Middleware</dc:title><dc:creator>CPONE Middleware</dc:creator><dc:subject>Database access reference</dc:subject></cp:coreProperties>'''
|
||||
app = f'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties"><Application>CPONE Middleware</Application></Properties>'''
|
||||
|
||||
with ZipFile(OUT, "w", ZIP_DEFLATED) as z:
|
||||
z.writestr("[Content_Types].xml", content_types)
|
||||
z.writestr("_rels/.rels", root_rels)
|
||||
z.writestr("word/document.xml", document)
|
||||
z.writestr("word/styles.xml", styles)
|
||||
z.writestr("word/numbering.xml", numbering)
|
||||
z.writestr("word/header1.xml", header)
|
||||
z.writestr("word/footer1.xml", footer)
|
||||
z.writestr("word/_rels/document.xml.rels", document_rels)
|
||||
z.writestr("docProps/core.xml", core)
|
||||
z.writestr("docProps/app.xml", app)
|
||||
|
||||
print(OUT)
|
||||
1417
docs/lap_mcu.php
Normal file
1417
docs/lap_mcu.php
Normal file
File diff suppressed because it is too large
Load Diff
1258
docs/resume_mcu.php
Normal file
1258
docs/resume_mcu.php
Normal file
File diff suppressed because it is too large
Load Diff
1
docs/template_mapping_layanan_his.csv
Normal file
1
docs/template_mapping_layanan_his.csv
Normal file
@@ -0,0 +1 @@
|
||||
Kode Layanan RS,Kode Test CPONE,Nama Layanan RS
|
||||
|
BIN
docs/template_mapping_layanan_his_RSBA.xlsx
Normal file
BIN
docs/template_mapping_layanan_his_RSBA.xlsx
Normal file
Binary file not shown.
@@ -3,7 +3,6 @@ package config
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -13,15 +12,17 @@ import (
|
||||
type Config struct {
|
||||
AppHost string
|
||||
AppPort string
|
||||
DBHost string
|
||||
DBPort string
|
||||
DBDatabase string
|
||||
DBUsername string
|
||||
DBPassword string
|
||||
RSCode string
|
||||
DepartmentID string
|
||||
BearerToken string
|
||||
DBMaxOpenConns int
|
||||
DBMaxIdleConns int
|
||||
DBConnMaxLifetime time.Duration
|
||||
HISDBHost string
|
||||
HISDBPort string
|
||||
HISDBDatabase string
|
||||
HISDBUsername string
|
||||
HISDBPassword string
|
||||
}
|
||||
|
||||
func Load(path string) (Config, error) {
|
||||
@@ -32,23 +33,31 @@ 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", ""),
|
||||
RSCode: env("CPONE_RS_CODE", ""),
|
||||
DepartmentID: env("CPONE_DEPARTEMEN_ID", ""),
|
||||
BearerToken: env("CPONE_BEARER_TOKEN", ""),
|
||||
DBMaxOpenConns: envInt("DB_MAX_OPEN_CONNS", 25),
|
||||
DBMaxIdleConns: envInt("DB_MAX_IDLE_CONNS", 25),
|
||||
DBConnMaxLifetime: time.Duration(envInt("DB_CONN_MAX_LIFETIME_MINUTES", 5)) * time.Minute,
|
||||
HISDBHost: env("CPONE_DB_HOST", "127.0.0.1"),
|
||||
HISDBPort: env("CPONE_DB_PORT", "3306"),
|
||||
HISDBDatabase: env("CPONE_DB_DATABASE", ""),
|
||||
HISDBUsername: env("CPONE_DB_USERNAME", ""),
|
||||
HISDBPassword: env("CPONE_DB_PASSWORD", ""),
|
||||
}
|
||||
|
||||
if cfg.DBDatabase == "" {
|
||||
return Config{}, fmt.Errorf("DB_DATABASE wajib diisi")
|
||||
if cfg.RSCode == "" {
|
||||
return Config{}, fmt.Errorf("CPONE_RS_CODE wajib diisi")
|
||||
}
|
||||
if cfg.DepartmentID == "" {
|
||||
return Config{}, fmt.Errorf("CPONE_DEPARTEMEN_ID wajib diisi")
|
||||
}
|
||||
if cfg.BearerToken == "" {
|
||||
return Config{}, fmt.Errorf("CPONE_BEARER_TOKEN wajib diisi")
|
||||
}
|
||||
if cfg.HISDBDatabase == "" || cfg.HISDBUsername == "" {
|
||||
return Config{}, fmt.Errorf("CPONE_DB_DATABASE dan CPONE_DB_USERNAME wajib diisi")
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
@@ -57,23 +66,6 @@ func (c Config) Addr() string {
|
||||
return c.AppHost + ":" + c.AppPort
|
||||
}
|
||||
|
||||
func (c Config) MySQLDSN() string {
|
||||
values := url.Values{}
|
||||
values.Set("charset", "utf8mb4")
|
||||
values.Set("parseTime", "true")
|
||||
values.Set("loc", "Local")
|
||||
|
||||
return fmt.Sprintf(
|
||||
"%s:%s@tcp(%s:%s)/%s?%s",
|
||||
c.DBUsername,
|
||||
c.DBPassword,
|
||||
c.DBHost,
|
||||
c.DBPort,
|
||||
c.DBDatabase,
|
||||
values.Encode(),
|
||||
)
|
||||
}
|
||||
|
||||
func loadDotEnv(path string) error {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
@@ -101,7 +93,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
105
internal/databaseconfig/static.go
Normal file
105
internal/databaseconfig/static.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package databaseconfig
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
mysql "github.com/go-sql-driver/mysql"
|
||||
"primaya-api/cpone-middleware/internal/repository"
|
||||
)
|
||||
|
||||
// ErrRSCodeMismatch indicates that a request was sent to the wrong hospital
|
||||
// instance. The code is a guard for this installation, not a database key.
|
||||
var ErrRSCodeMismatch = errors.New("kode RS tidak sesuai dengan instance CPONE")
|
||||
|
||||
type Setting struct {
|
||||
RSCode string `json:"kode_rs"`
|
||||
Name string `json:"nama"`
|
||||
Host string `json:"host"`
|
||||
Port string `json:"port"`
|
||||
Database string `json:"database"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type PoolConfig struct {
|
||||
MaxOpenConns int
|
||||
MaxIdleConns int
|
||||
ConnMaxLifetime time.Duration
|
||||
}
|
||||
|
||||
func NormalizeRSCode(code string) string { return strings.ToUpper(strings.TrimSpace(code)) }
|
||||
|
||||
// StaticResolver owns one HIS connection for one CPONE installation. Resolve
|
||||
// only validates the request code and always returns the same repository.
|
||||
type StaticResolver struct {
|
||||
code string
|
||||
db *sql.DB
|
||||
repo repository.MySQLLayananRepository
|
||||
}
|
||||
|
||||
func NewStaticResolver(ctx context.Context, code string, setting Setting, pool PoolConfig) (*StaticResolver, error) {
|
||||
code = NormalizeRSCode(code)
|
||||
if code == "" {
|
||||
return nil, errors.New("CPONE_RS_CODE wajib diisi")
|
||||
}
|
||||
if setting.Host == "" || setting.Port == "" || setting.Database == "" || setting.Username == "" {
|
||||
return nil, errors.New("host, port, database, dan username HIS wajib diisi")
|
||||
}
|
||||
port, err := strconv.Atoi(setting.Port)
|
||||
if err != nil || port < 1 || port > 65535 {
|
||||
return nil, errors.New("port database HIS harus berupa angka antara 1 sampai 65535")
|
||||
}
|
||||
|
||||
db, err := sql.Open("mysql", mysqlDSN(setting))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
applyPoolConfig(db, pool)
|
||||
if err := db.PingContext(ctx); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, fmt.Errorf("koneksi database HIS gagal: %w", err)
|
||||
}
|
||||
return &StaticResolver{code: code, db: db, repo: repository.NewMySQLLayananRepository(db)}, nil
|
||||
}
|
||||
|
||||
func (r *StaticResolver) Resolve(_ context.Context, code string) (repository.MySQLLayananRepository, error) {
|
||||
if NormalizeRSCode(code) != r.code {
|
||||
return repository.MySQLLayananRepository{}, ErrRSCodeMismatch
|
||||
}
|
||||
return r.repo, nil
|
||||
}
|
||||
|
||||
func (r *StaticResolver) Close() error {
|
||||
if r == nil || r.db == nil {
|
||||
return nil
|
||||
}
|
||||
return r.db.Close()
|
||||
}
|
||||
|
||||
func (r *StaticResolver) Code() string { return strings.TrimSpace(r.code) }
|
||||
|
||||
func applyPoolConfig(db *sql.DB, pool PoolConfig) {
|
||||
db.SetMaxOpenConns(pool.MaxOpenConns)
|
||||
db.SetMaxIdleConns(pool.MaxIdleConns)
|
||||
db.SetConnMaxLifetime(pool.ConnMaxLifetime)
|
||||
}
|
||||
|
||||
func mysqlDSN(setting Setting) string {
|
||||
cfg := mysql.NewConfig()
|
||||
cfg.User = setting.Username
|
||||
cfg.Passwd = setting.Password
|
||||
cfg.Net = "tcp"
|
||||
cfg.Addr = net.JoinHostPort(setting.Host, setting.Port)
|
||||
cfg.DBName = setting.Database
|
||||
cfg.ParseTime = true
|
||||
cfg.Loc = time.Local
|
||||
cfg.Params = map[string]string{"charset": "utf8mb4"}
|
||||
return cfg.FormatDSN()
|
||||
}
|
||||
17
internal/databaseconfig/static_test.go
Normal file
17
internal/databaseconfig/static_test.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package databaseconfig
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestStaticResolverValidatesCodeWithoutSelectingDatabase(t *testing.T) {
|
||||
resolver := &StaticResolver{code: "RS_BEKASI"}
|
||||
if _, err := resolver.Resolve(context.Background(), " rs_bekasi "); err != nil {
|
||||
t.Fatalf("matching code returned error: %v", err)
|
||||
}
|
||||
if _, err := resolver.Resolve(context.Background(), "RS_JAKARTA"); !errors.Is(err, ErrRSCodeMismatch) {
|
||||
t.Fatalf("wrong code error = %v, want ErrRSCodeMismatch", err)
|
||||
}
|
||||
}
|
||||
103
internal/http/dokter.go
Normal file
103
internal/http/dokter.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"primaya-api/cpone-middleware/internal/repository"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultDokterPerPage = 15
|
||||
maxDokterPerPage = 100
|
||||
)
|
||||
|
||||
type DokterPaginationResponse struct {
|
||||
CurrentPage int `json:"current_page"`
|
||||
Data []repository.Dokter `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) ListDokter(w http.ResponseWriter, r *http.Request) {
|
||||
page := parseDokterPage(r.URL.Query().Get("page"))
|
||||
perPage := parseDokterPerPage(r.URL.Query().Get("per_page"))
|
||||
|
||||
doctorIDs := queryValues(r, "DokterID_in", "DokterID_in[]")
|
||||
doctorIDs = splitDoctorIDs(doctorIDs)
|
||||
|
||||
result, err := h.dokterRepository(r.Context()).ListDokter(r.Context(), repository.DokterListParams{
|
||||
Search: strings.TrimSpace(r.URL.Query().Get("search")), DoctorIDs: doctorIDs,
|
||||
Page: page, PerPage: perPage,
|
||||
})
|
||||
if err != nil {
|
||||
WriteJSON(w, http.StatusInternalServerError, Response{Success: false, Message: "internal server error", Data: nil})
|
||||
return
|
||||
}
|
||||
|
||||
lastPage := (result.Total + perPage - 1) / perPage
|
||||
if lastPage < 1 {
|
||||
lastPage = 1
|
||||
}
|
||||
var from *int
|
||||
if len(result.Items) > 0 {
|
||||
value := (page-1)*perPage + 1
|
||||
from = &value
|
||||
}
|
||||
var nextPageURL *string
|
||||
if page < lastPage {
|
||||
value := currentRequestURL(r, page+1)
|
||||
nextPageURL = &value
|
||||
}
|
||||
|
||||
WriteJSON(w, http.StatusOK, DokterPaginationResponse{
|
||||
CurrentPage: page, Data: result.Items, FirstPageURL: "", From: from,
|
||||
LastPage: lastPage, LastPageURL: "", Links: map[string]any{},
|
||||
NextPageURL: nextPageURL, Total: result.Total, PerPage: perPage,
|
||||
})
|
||||
}
|
||||
|
||||
func parseDokterPage(raw string) int {
|
||||
value, err := strconv.Atoi(raw)
|
||||
if err != nil || value < 1 {
|
||||
return 1
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func parseDokterPerPage(raw string) int {
|
||||
value, err := strconv.Atoi(raw)
|
||||
if err != nil || value < 1 {
|
||||
return defaultDokterPerPage
|
||||
}
|
||||
if value > maxDokterPerPage {
|
||||
return maxDokterPerPage
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func splitDoctorIDs(values []string) []string {
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
result := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
for _, part := range strings.Split(value, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[part]; exists {
|
||||
continue
|
||||
}
|
||||
seen[part] = struct{}{}
|
||||
result = append(result, part)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
72
internal/http/dokter_test.go
Normal file
72
internal/http/dokter_test.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"primaya-api/cpone-middleware/internal/repository"
|
||||
)
|
||||
|
||||
type fakeDokterRepository struct {
|
||||
page repository.DokterPage
|
||||
params repository.DokterListParams
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeDokterRepository) ListDokter(_ context.Context, params repository.DokterListParams) (repository.DokterPage, error) {
|
||||
f.params = params
|
||||
return f.page, f.err
|
||||
}
|
||||
|
||||
func TestListDokterResponseParity(t *testing.T) {
|
||||
name := "DR. ANDI"
|
||||
repo := &fakeDokterRepository{page: repository.DokterPage{
|
||||
Total: 16,
|
||||
Items: []repository.Dokter{{DokterID: "D001", Nama: &name}},
|
||||
}}
|
||||
handler := Handler{dokterRepo: repo}
|
||||
request := httptest.NewRequest(http.MethodGet, "http://localhost/api/cpone/doctors?search=andi&DokterID_in=D001,D002&page=1&per_page=15&kode_rs=AWALBROS_DEV", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.ListDokter(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if repo.params.Search != "andi" || repo.params.Page != 1 || repo.params.PerPage != 15 || strings.Join(repo.params.DoctorIDs, ",") != "D001,D002" {
|
||||
t.Fatalf("params=%+v", repo.params)
|
||||
}
|
||||
for _, expected := range []string{
|
||||
`"DokterID":"D001"`, `"Nama":"DR. ANDI"`,
|
||||
`"last_page":2`, `"per_page":15`,
|
||||
`"next_page_url":"http://localhost/api/cpone/doctors?kode_rs=AWALBROS_DEV\u0026page=2"`,
|
||||
} {
|
||||
if !strings.Contains(recorder.Body.String(), expected) {
|
||||
t.Fatalf("response missing %s: %s", expected, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
for _, excluded := range []string{`"spesialisasi"`, `"subspesialisasi"`, `"departementIDs"`, `"profileDokter"`} {
|
||||
if strings.Contains(recorder.Body.String(), excluded) {
|
||||
t.Fatalf("response should not contain %s: %s", excluded, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListDokterDefaultsAndPerPageLimit(t *testing.T) {
|
||||
repo := &fakeDokterRepository{page: repository.DokterPage{Items: []repository.Dokter{}}}
|
||||
handler := Handler{dokterRepo: repo}
|
||||
|
||||
for target, expected := range map[string]int{
|
||||
"/api/cpone/doctors": defaultDokterPerPage,
|
||||
"/api/cpone/doctors?page=0": defaultDokterPerPage,
|
||||
"/api/cpone/doctors?per_page=250": maxDokterPerPage,
|
||||
} {
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.ListDokter(recorder, httptest.NewRequest(http.MethodGet, target, nil))
|
||||
if recorder.Code != http.StatusOK || repo.params.PerPage != expected || repo.params.Page != 1 {
|
||||
t.Fatalf("target=%s status=%d params=%+v", target, recorder.Code, repo.params)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,18 +13,24 @@ 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)
|
||||
}
|
||||
|
||||
type LabRegistrationRepository interface {
|
||||
RegisterLab(ctx context.Context, params repository.LabRegistrationParams) (repository.LabRegistrationOutcome, error)
|
||||
AddLabServices(ctx context.Context, regID string, params repository.AddLabServicesParams) (repository.LabRegistrationOutcome, error)
|
||||
AddLabPackage(ctx context.Context, regID string, params repository.AddLabPackageParams) (repository.LabRegistrationOutcome, error)
|
||||
}
|
||||
|
||||
type PatientRepository interface {
|
||||
StoreMedrec(ctx context.Context, params repository.MedrecParams) (repository.MedrecOutcome, error)
|
||||
}
|
||||
|
||||
type RegistrationRepository interface {
|
||||
RegisterPatient(ctx context.Context, params repository.RegistrationParams) (repository.RegistrationOutcome, error)
|
||||
}
|
||||
|
||||
type LabResultRepository interface {
|
||||
GetLabResult(ctx context.Context, labNumber string) (repository.LabResultOutcome, error)
|
||||
}
|
||||
@@ -32,6 +38,7 @@ type LabResultRepository interface {
|
||||
type PaketRepository interface {
|
||||
ListPaket(ctx context.Context, params repository.PaketListParams) (repository.PaketPage, error)
|
||||
GetPaket(ctx context.Context, id string) (*repository.PaketDetail, error)
|
||||
CreatePaket(ctx context.Context, params repository.CreatePaketParams) (repository.CreatePaketResult, error)
|
||||
}
|
||||
|
||||
type TarifKerjasamaRepository interface {
|
||||
@@ -39,29 +46,164 @@ type TarifKerjasamaRepository interface {
|
||||
GetTarifKerjasama(ctx context.Context, id int64, params repository.TarifKerjasamaDetailParams) (*repository.TarifKerjasamaDetail, error)
|
||||
}
|
||||
|
||||
type PeriodeTarifRepository interface {
|
||||
ListPeriodeTarif(ctx context.Context) ([]repository.PeriodeTarif, error)
|
||||
}
|
||||
|
||||
type PeriodeTarifDetailRepository interface {
|
||||
ListPeriodeTarifLayanan(ctx context.Context, params repository.PeriodeTarifLayananListParams) (repository.PeriodeTarifLayananPage, 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 DokterRepository interface {
|
||||
ListDokter(ctx context.Context, params repository.DokterListParams) (repository.DokterPage, error)
|
||||
}
|
||||
|
||||
type LaporanMCURepository interface {
|
||||
ListLaporanMCU(ctx context.Context, params repository.LaporanMCUParams) ([]repository.LaporanMCURow, error)
|
||||
}
|
||||
|
||||
type Handler struct {
|
||||
layananRepo LayananRepository
|
||||
labRepo LabRegistrationRepository
|
||||
patientRepo PatientRepository
|
||||
labResultRepo LabResultRepository
|
||||
paketRepo PaketRepository
|
||||
tarifRepo TarifKerjasamaRepository
|
||||
layananRepo LayananRepository
|
||||
labRepo LabRegistrationRepository
|
||||
patientRepo PatientRepository
|
||||
registrationRepo RegistrationRepository
|
||||
labResultRepo LabResultRepository
|
||||
paketRepo PaketRepository
|
||||
tarifRepo TarifKerjasamaRepository
|
||||
periodeTarifRepo PeriodeTarifRepository
|
||||
periodeTarifDetailRepo PeriodeTarifDetailRepository
|
||||
perusahaanRepo PerusahaanRepository
|
||||
dokterRepo DokterRepository
|
||||
laporanMCURepo LaporanMCURepository
|
||||
hospitalCode string
|
||||
departmentID string
|
||||
}
|
||||
|
||||
type CPONERepository interface {
|
||||
LayananRepository
|
||||
LabRegistrationRepository
|
||||
PatientRepository
|
||||
RegistrationRepository
|
||||
LabResultRepository
|
||||
PaketRepository
|
||||
TarifKerjasamaRepository
|
||||
PeriodeTarifRepository
|
||||
PeriodeTarifDetailRepository
|
||||
PerusahaanRepository
|
||||
DokterRepository
|
||||
}
|
||||
|
||||
func NewHandler(repo CPONERepository) Handler {
|
||||
return Handler{
|
||||
layananRepo: repo, labRepo: repo, patientRepo: repo,
|
||||
handler := Handler{
|
||||
layananRepo: repo, labRepo: repo, patientRepo: repo, registrationRepo: repo,
|
||||
labResultRepo: repo, paketRepo: repo, tarifRepo: repo,
|
||||
periodeTarifRepo: repo, periodeTarifDetailRepo: repo, perusahaanRepo: repo,
|
||||
dokterRepo: repo,
|
||||
}
|
||||
// Keep NewHandler source-compatible with repositories created before the
|
||||
// MCU report was added. The concrete MySQL repository implements this
|
||||
// optional capability and the dynamic production handler uses it.
|
||||
if reportRepo, ok := any(repo).(LaporanMCURepository); ok {
|
||||
handler.laporanMCURepo = reportRepo
|
||||
}
|
||||
return handler
|
||||
}
|
||||
|
||||
func NewDynamicHandler(hospitalCode string, departmentID string) Handler {
|
||||
return Handler{
|
||||
hospitalCode: strings.ToUpper(strings.TrimSpace(hospitalCode)),
|
||||
departmentID: strings.TrimSpace(departmentID),
|
||||
}
|
||||
}
|
||||
|
||||
func (h Handler) layananRepository(ctx context.Context) LayananRepository {
|
||||
if repo, ok := selectedRepository(ctx); ok {
|
||||
return repo
|
||||
}
|
||||
return h.layananRepo
|
||||
}
|
||||
|
||||
func (h Handler) labRepository(ctx context.Context) LabRegistrationRepository {
|
||||
if repo, ok := selectedRepository(ctx); ok {
|
||||
return repo
|
||||
}
|
||||
return h.labRepo
|
||||
}
|
||||
|
||||
func (h Handler) patientRepository(ctx context.Context) PatientRepository {
|
||||
if repo, ok := selectedRepository(ctx); ok {
|
||||
return repo
|
||||
}
|
||||
return h.patientRepo
|
||||
}
|
||||
|
||||
func (h Handler) registrationRepository(ctx context.Context) RegistrationRepository {
|
||||
if repo, ok := selectedRepository(ctx); ok {
|
||||
return repo
|
||||
}
|
||||
return h.registrationRepo
|
||||
}
|
||||
|
||||
func (h Handler) labResultRepository(ctx context.Context) LabResultRepository {
|
||||
if repo, ok := selectedRepository(ctx); ok {
|
||||
return repo
|
||||
}
|
||||
return h.labResultRepo
|
||||
}
|
||||
|
||||
func (h Handler) paketRepository(ctx context.Context) PaketRepository {
|
||||
if repo, ok := selectedRepository(ctx); ok {
|
||||
return repo
|
||||
}
|
||||
return h.paketRepo
|
||||
}
|
||||
|
||||
func (h Handler) tarifRepository(ctx context.Context) TarifKerjasamaRepository {
|
||||
if repo, ok := selectedRepository(ctx); ok {
|
||||
return repo
|
||||
}
|
||||
return h.tarifRepo
|
||||
}
|
||||
|
||||
func (h Handler) periodeTarifRepository(ctx context.Context) PeriodeTarifRepository {
|
||||
if repo, ok := selectedRepository(ctx); ok {
|
||||
return repo
|
||||
}
|
||||
return h.periodeTarifRepo
|
||||
}
|
||||
|
||||
func (h Handler) periodeTarifDetailRepository(ctx context.Context) PeriodeTarifDetailRepository {
|
||||
if repo, ok := selectedRepository(ctx); ok {
|
||||
return repo
|
||||
}
|
||||
return h.periodeTarifDetailRepo
|
||||
}
|
||||
|
||||
func (h Handler) perusahaanRepository(ctx context.Context) PerusahaanRepository {
|
||||
if repo, ok := selectedRepository(ctx); ok {
|
||||
return repo
|
||||
}
|
||||
return h.perusahaanRepo
|
||||
}
|
||||
|
||||
func (h Handler) dokterRepository(ctx context.Context) DokterRepository {
|
||||
if repo, ok := selectedRepository(ctx); ok {
|
||||
return repo
|
||||
}
|
||||
return h.dokterRepo
|
||||
}
|
||||
|
||||
func (h Handler) laporanMCURepository(ctx context.Context) LaporanMCURepository {
|
||||
if repo, ok := selectedRepository(ctx); ok {
|
||||
return repo
|
||||
}
|
||||
return h.laporanMCURepo
|
||||
}
|
||||
|
||||
func (h Handler) MappingHarga(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -95,7 +237,7 @@ func (h Handler) MappingHarga(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := h.layananRepo.MappingHarga(r.Context(), repository.MappingHargaParams{
|
||||
rows, err := h.layananRepository(r.Context()).MappingHarga(r.Context(), repository.MappingHargaParams{
|
||||
PeriodeTarifID: periodeTarifID,
|
||||
LayananIDs: layananIDs,
|
||||
Search: strings.TrimSpace(r.URL.Query().Get("search")),
|
||||
|
||||
84
internal/http/lab_package.go
Normal file
84
internal/http/lab_package.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"primaya-api/cpone-middleware/internal/repository"
|
||||
)
|
||||
|
||||
var labIdentifier = regexp.MustCompile(`^[A-Za-z0-9-]+$`)
|
||||
|
||||
func (h Handler) AddLabPackage(w http.ResponseWriter, r *http.Request) {
|
||||
regID := strings.TrimSpace(r.PathValue("regId"))
|
||||
if regID == "" || utf8.RuneCountInString(regID) > 50 || !labIdentifier.MatchString(regID) {
|
||||
writeLabResponse(w, http.StatusUnprocessableEntity, false, "validation_error", "Nomor registrasi laboratorium hanya boleh berisi huruf, angka, dan tanda hubung", nil)
|
||||
return
|
||||
}
|
||||
payload, _ := decodeJSONObject(r)
|
||||
params, message := validateAddLabPackage(payload)
|
||||
if message != "" {
|
||||
writeLabResponse(w, http.StatusUnprocessableEntity, false, "validation_error", message, nil)
|
||||
return
|
||||
}
|
||||
outcome, err := h.labRepository(r.Context()).AddLabPackage(r.Context(), strings.ToUpper(regID), params)
|
||||
if err != nil {
|
||||
if validationMessage, ok := asValidationError(err); ok {
|
||||
writeLabResponse(w, http.StatusUnprocessableEntity, false, "validation_error", validationMessage, nil)
|
||||
return
|
||||
}
|
||||
writeLabResponse(w, http.StatusInternalServerError, false, "error", err.Error(), nil)
|
||||
return
|
||||
}
|
||||
writeLabResponse(w, outcome.HTTPStatus, outcome.Success, outcome.Status, outcome.Message, outcome.Data)
|
||||
}
|
||||
|
||||
func validateAddLabPackage(payload map[string]any) (repository.AddLabPackageParams, string) {
|
||||
packageID, message := requiredString(payload, "paket_id", 50, "Paket ID wajib diisi")
|
||||
if message != "" {
|
||||
return repository.AddLabPackageParams{}, message
|
||||
}
|
||||
packageID = strings.ToUpper(strings.TrimSpace(packageID))
|
||||
if !labIdentifier.MatchString(packageID) {
|
||||
return repository.AddLabPackageParams{}, "Paket ID hanya boleh berisi huruf, angka, dan tanda hubung"
|
||||
}
|
||||
cito := false
|
||||
if value, present := nullableValue(payload, "cito"); present {
|
||||
var valid bool
|
||||
cito, valid = parseLaravelBoolean(value)
|
||||
if !valid {
|
||||
return repository.AddLabPackageParams{}, "The cito field must be true or false."
|
||||
}
|
||||
}
|
||||
markup := 0.0
|
||||
if value, present := nullableValue(payload, "markup_cito"); present {
|
||||
var valid bool
|
||||
markup, valid = parseNumeric(value)
|
||||
if !valid {
|
||||
return repository.AddLabPackageParams{}, "The markup cito must be a number."
|
||||
}
|
||||
if markup < 0 || markup > 100 {
|
||||
return repository.AddLabPackageParams{}, "The markup cito must be between 0 and 100."
|
||||
}
|
||||
}
|
||||
if !cito {
|
||||
markup = 0
|
||||
}
|
||||
notes, _, message := optionalString(payload, "catatan", 500)
|
||||
if message != "" {
|
||||
return repository.AddLabPackageParams{}, message
|
||||
}
|
||||
channel, present, message := optionalString(payload, "channel", 50)
|
||||
if message != "" {
|
||||
return repository.AddLabPackageParams{}, message
|
||||
}
|
||||
if !present {
|
||||
channel = "CPONE"
|
||||
}
|
||||
return repository.AddLabPackageParams{
|
||||
PackageID: packageID, Cito: cito, MarkupCito: markup,
|
||||
Notes: strings.TrimSpace(notes), Channel: strings.ToUpper(strings.TrimSpace(channel)),
|
||||
}, ""
|
||||
}
|
||||
50
internal/http/lab_package_test.go
Normal file
50
internal/http/lab_package_test.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"primaya-api/cpone-middleware/internal/repository"
|
||||
)
|
||||
|
||||
func TestAddLabPackageValidPayload(t *testing.T) {
|
||||
repo := &fakeLabRegistrationRepository{outcome: repository.LabRegistrationOutcome{
|
||||
HTTPStatus: 201, Success: true, Status: "package_added",
|
||||
Message: "Paket dispenser berhasil ditambahkan ke registrasi laboratorium",
|
||||
}}
|
||||
handler := Handler{labRepo: repo}
|
||||
request := httptest.NewRequest(http.MethodPost, "/packages", strings.NewReader(`{"paket_id":"disp-260800011","cito":true,"markup_cito":"10","catatan":" test ","channel":"cpone"}`))
|
||||
request.SetPathValue("regId", "lab-2608210003")
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.AddLabPackage(recorder, request)
|
||||
if recorder.Code != http.StatusCreated {
|
||||
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if repo.regID != "LAB-2608210003" || repo.packageParams.PackageID != "DISP-260800011" || !repo.packageParams.Cito || repo.packageParams.MarkupCito != 10 || repo.packageParams.Notes != "test" || repo.packageParams.Channel != "CPONE" {
|
||||
t.Fatalf("unexpected repository call: reg=%q params=%+v", repo.regID, repo.packageParams)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddLabPackageValidation(t *testing.T) {
|
||||
tests := []struct{ name, regID, body, message string }{
|
||||
{"invalid registration", "LAB/1", `{"paket_id":"DISP-1"}`, "Nomor registrasi laboratorium hanya boleh berisi huruf, angka, dan tanda hubung"},
|
||||
{"missing package", "LAB-1", `{}`, "Paket ID wajib diisi"},
|
||||
{"invalid package", "LAB-1", `{"paket_id":"DISP/1"}`, "Paket ID hanya boleh berisi huruf, angka, dan tanda hubung"},
|
||||
{"invalid cito", "LAB-1", `{"paket_id":"DISP-1","cito":"true"}`, "The cito field must be true or false."},
|
||||
{"invalid markup", "LAB-1", `{"paket_id":"DISP-1","markup_cito":101}`, "The markup cito must be between 0 and 100."},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
handler := Handler{labRepo: &fakeLabRegistrationRepository{}}
|
||||
request := httptest.NewRequest(http.MethodPost, "/packages", strings.NewReader(test.body))
|
||||
request.SetPathValue("regId", test.regID)
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.AddLabPackage(recorder, request)
|
||||
if recorder.Code != 422 || !strings.Contains(recorder.Body.String(), test.message) {
|
||||
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,11 @@ func (h Handler) RegisterLab(w http.ResponseWriter, r *http.Request) {
|
||||
writeLabResponse(w, http.StatusUnprocessableEntity, false, "validation_error", err.Error(), nil)
|
||||
return
|
||||
}
|
||||
// Departemen registrasi ditentukan per instance CPONE melalui .env.
|
||||
// Payload tetap didukung untuk handler/test lama yang tidak memakai config.
|
||||
if h.departmentID != "" {
|
||||
payload["departemen_id"] = h.departmentID
|
||||
}
|
||||
|
||||
validated, validationMessage := validateLabRegistration(payload)
|
||||
if validationMessage != "" {
|
||||
@@ -42,7 +47,7 @@ func (h Handler) RegisterLab(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
outcome, err := h.labRepo.RegisterLab(r.Context(), validated.Params)
|
||||
outcome, err := h.labRepository(r.Context()).RegisterLab(r.Context(), validated.Params)
|
||||
if err != nil {
|
||||
var validationErr repository.ValidationError
|
||||
if errors.As(err, &validationErr) {
|
||||
@@ -115,13 +120,11 @@ func validateLabRegistration(payload map[string]any) (validatedLabRegistration,
|
||||
return validatedLabRegistration{}, "Dokter pengirim atau perujuk wajib diisi"
|
||||
}
|
||||
|
||||
patientType, patientTypePresent, message := optionalString(payload, "jenis_pasien_id", 0)
|
||||
patientType, message := requiredString(payload, "jenis_pasien_id", 20, "Jenis pasien wajib diisi")
|
||||
if message != "" {
|
||||
return validatedLabRegistration{}, message
|
||||
}
|
||||
if patientTypePresent && patientType != "0" {
|
||||
return validatedLabRegistration{}, "Registrasi laboratorium API saat ini hanya mendukung pasien umum"
|
||||
}
|
||||
patientType = strings.TrimSpace(patientType)
|
||||
|
||||
tariffPeriodID, message := requiredInteger(payload, "periode_tarif_id", "Periode tarif wajib diisi")
|
||||
if message != "" {
|
||||
@@ -131,6 +134,60 @@ func validateLabRegistration(payload map[string]any) (validatedLabRegistration,
|
||||
return validatedLabRegistration{}, "The periode tarif id must be at least 1."
|
||||
}
|
||||
|
||||
companyID, companyPresent, message := optionalString(payload, "perusahaan_id", 50)
|
||||
if message != "" {
|
||||
return validatedLabRegistration{}, message
|
||||
}
|
||||
insuranceID, insurancePresent, message := optionalString(payload, "asuransi_id", 50)
|
||||
if message != "" {
|
||||
return validatedLabRegistration{}, message
|
||||
}
|
||||
providerID, _, message := optionalString(payload, "provider_id", 50)
|
||||
if message != "" {
|
||||
return validatedLabRegistration{}, message
|
||||
}
|
||||
policyNumber, _, message := optionalString(payload, "nomer_polis", 100)
|
||||
if message != "" {
|
||||
return validatedLabRegistration{}, message
|
||||
}
|
||||
employeeID, _, message := optionalString(payload, "nik_karyawan", 100)
|
||||
if message != "" {
|
||||
return validatedLabRegistration{}, message
|
||||
}
|
||||
employeeName, _, message := optionalString(payload, "nama_karyawan", 255)
|
||||
if message != "" {
|
||||
return validatedLabRegistration{}, message
|
||||
}
|
||||
|
||||
var groupTariffID int64
|
||||
if value, present := nullableValue(payload, "group_tarif_id"); present {
|
||||
parsed, ok := parseInteger(value)
|
||||
if !ok {
|
||||
return validatedLabRegistration{}, "The group tarif id must be an integer."
|
||||
}
|
||||
if parsed < 1 && patientType != "0" {
|
||||
return validatedLabRegistration{}, "The group tarif id must be at least 1."
|
||||
}
|
||||
groupTariffID = parsed
|
||||
}
|
||||
|
||||
if patientType != "0" {
|
||||
if !companyPresent || strings.TrimSpace(companyID) == "" {
|
||||
return validatedLabRegistration{}, "Perusahaan wajib diisi untuk pasien penjamin"
|
||||
}
|
||||
if !insurancePresent || strings.TrimSpace(insuranceID) == "" {
|
||||
return validatedLabRegistration{}, "Asuransi/penanggung wajib diisi untuk pasien penjamin"
|
||||
}
|
||||
if groupTariffID < 1 {
|
||||
return validatedLabRegistration{}, "Group tarif wajib diisi untuk pasien penjamin"
|
||||
}
|
||||
} else {
|
||||
companyID = ""
|
||||
insuranceID = ""
|
||||
providerID = ""
|
||||
groupTariffID = 0
|
||||
}
|
||||
|
||||
classID, classPresent, message := optionalString(payload, "kelas_id", 50)
|
||||
if message != "" {
|
||||
return validatedLabRegistration{}, message
|
||||
@@ -184,7 +241,15 @@ func validateLabRegistration(payload map[string]any) (validatedLabRegistration,
|
||||
DoctorID: doctorID,
|
||||
SenderDoctorID: senderDoctorID,
|
||||
Referrer: referrer,
|
||||
PatientType: patientType,
|
||||
TariffPeriodID: tariffPeriodID,
|
||||
CompanyID: strings.TrimSpace(companyID),
|
||||
InsuranceID: strings.TrimSpace(insuranceID),
|
||||
ProviderID: strings.TrimSpace(providerID),
|
||||
GroupTariffID: groupTariffID,
|
||||
PolicyNumber: strings.TrimSpace(policyNumber),
|
||||
EmployeeID: strings.TrimSpace(employeeID),
|
||||
EmployeeName: strings.TrimSpace(employeeName),
|
||||
ClassID: classID,
|
||||
DailyShiftID: dailyShiftID,
|
||||
WorkingDiagnosis: workingDiagnosis,
|
||||
|
||||
@@ -13,11 +13,12 @@ import (
|
||||
)
|
||||
|
||||
type fakeLabRegistrationRepository struct {
|
||||
outcome repository.LabRegistrationOutcome
|
||||
err error
|
||||
params repository.LabRegistrationParams
|
||||
addParams repository.AddLabServicesParams
|
||||
regID string
|
||||
outcome repository.LabRegistrationOutcome
|
||||
err error
|
||||
params repository.LabRegistrationParams
|
||||
addParams repository.AddLabServicesParams
|
||||
packageParams repository.AddLabPackageParams
|
||||
regID string
|
||||
}
|
||||
|
||||
func (f *fakeLabRegistrationRepository) RegisterLab(_ context.Context, params repository.LabRegistrationParams) (repository.LabRegistrationOutcome, error) {
|
||||
@@ -31,6 +32,12 @@ func (f *fakeLabRegistrationRepository) AddLabServices(_ context.Context, regID
|
||||
return f.outcome, f.err
|
||||
}
|
||||
|
||||
func (f *fakeLabRegistrationRepository) AddLabPackage(_ context.Context, regID string, params repository.AddLabPackageParams) (repository.LabRegistrationOutcome, error) {
|
||||
f.regID = regID
|
||||
f.packageParams = params
|
||||
return f.outcome, f.err
|
||||
}
|
||||
|
||||
func TestRegisterLabValidPayloadAndResponseParity(t *testing.T) {
|
||||
doctorID := "DR01"
|
||||
registrationType := int64(3)
|
||||
@@ -80,7 +87,7 @@ func TestRegisterLabValidPayloadAndResponseParity(t *testing.T) {
|
||||
if recorder.Code != http.StatusCreated {
|
||||
t.Fatalf("status = %d, want %d; body=%s", recorder.Code, http.StatusCreated, recorder.Body.String())
|
||||
}
|
||||
const expected = `{"success":true,"status":"registered","message":"Registrasi laboratorium berhasil disimpan","data":{"registration":{"RegID":"CPLAB2608180001","MEDRECID":"00001234","Tanggal":"2026-08-18 10:11:12","DepartemenID":"LAB","DokterID":"DR01","JenisPasienID":0,"JenisRegID":3,"PeriodeTarifID":10},"transaction":{"TrxDepartemenID":99,"TrxID":"CPLAB2608180001","GrandTotal":0},"layanan":[]}}` + "\n"
|
||||
const expected = `{"success":true,"status":"registered","message":"Registrasi laboratorium berhasil disimpan","data":{"registration":{"RegID":"CPLAB2608180001","MEDRECID":"00001234","Tanggal":"2026-08-18 10:11:12","DepartemenID":"LAB","DokterID":"DR01","JenisPasienID":0,"JenisRegID":3,"PeriodeTarifID":10,"PerusahaanID":null,"AsuransiID":null,"ProviderID":null,"GroupTarifID":null,"NomerPolis":null},"transaction":{"TrxDepartemenID":99,"TrxID":"CPLAB2608180001","GrandTotal":0},"layanan":[]}}` + "\n"
|
||||
if recorder.Body.String() != expected {
|
||||
t.Fatalf("body mismatch\n got: %s\nwant: %s", recorder.Body.String(), expected)
|
||||
}
|
||||
@@ -96,12 +103,87 @@ func TestRegisterLabValidPayloadAndResponseParity(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterLabUsesConfiguredDepartmentID(t *testing.T) {
|
||||
repo := &fakeLabRegistrationRepository{outcome: repository.LabRegistrationOutcome{
|
||||
HTTPStatus: http.StatusCreated,
|
||||
Success: true,
|
||||
Status: "registered",
|
||||
Message: "Registrasi laboratorium berhasil disimpan",
|
||||
}}
|
||||
handler := Handler{labRepo: repo, departmentID: "MED-PNJ-LAB"}
|
||||
body := `{
|
||||
"medrec_id":"00001234",
|
||||
"departemen_id":"DIABAIKAN",
|
||||
"dokter_id":"DR01",
|
||||
"dokter_pengirim_id":"DR02",
|
||||
"jenis_pasien_id":"0",
|
||||
"periode_tarif_id":10,
|
||||
"diagnosa_kerja":"Pemeriksaan laboratorium"
|
||||
}`
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/cpone/patients/lab-registration", strings.NewReader(body))
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.RegisterLab(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusCreated {
|
||||
t.Fatalf("status = %d; body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if repo.params.DepartmentID != "MED-PNJ-LAB" {
|
||||
t.Fatalf("department ID = %q, want %q", repo.params.DepartmentID, "MED-PNJ-LAB")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterLabCompanyPayload(t *testing.T) {
|
||||
repo := &fakeLabRegistrationRepository{outcome: repository.LabRegistrationOutcome{
|
||||
HTTPStatus: http.StatusCreated,
|
||||
Success: true,
|
||||
Status: "registered",
|
||||
Message: "Registrasi laboratorium berhasil disimpan",
|
||||
}}
|
||||
handler := Handler{labRepo: repo}
|
||||
body := `{
|
||||
"medrec_id":"00409575",
|
||||
"departemen_id":"MED-PNJ-LAB",
|
||||
"dokter_id":"SPP01_003",
|
||||
"dokter_pengirim_id":"Luar",
|
||||
"perujuk":"DOKTER PERUJUK LUAR",
|
||||
"jenis_pasien_id":"1",
|
||||
"periode_tarif_id":7,
|
||||
"perusahaan_id":"PE260700001",
|
||||
"asuransi_id":"PE260700001",
|
||||
"provider_id":"",
|
||||
"group_tarif_id":1980,
|
||||
"nomer_polis":"POLIS-001",
|
||||
"nik_karyawan":"TSB2026002",
|
||||
"nama_karyawan":"INTAN SEPTIANI",
|
||||
"kelas_id":"Default",
|
||||
"shift_harian_id":48049,
|
||||
"diagnosa_kerja":"Test diagnosa"
|
||||
}`
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/cpone/patients/lab-registration", strings.NewReader(body))
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.RegisterLab(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusCreated {
|
||||
t.Fatalf("status = %d, want %d; body=%s", recorder.Code, http.StatusCreated, recorder.Body.String())
|
||||
}
|
||||
params := repo.params
|
||||
if params.PatientType != "1" || params.CompanyID != "PE260700001" || params.InsuranceID != "PE260700001" || params.GroupTariffID != 1980 {
|
||||
t.Fatalf("company guarantor params mismatch: %+v", params)
|
||||
}
|
||||
if params.PolicyNumber != "POLIS-001" || params.EmployeeID != "TSB2026002" || params.EmployeeName != "INTAN SEPTIANI" {
|
||||
t.Fatalf("company employee params mismatch: %+v", params)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterLabValidationParity(t *testing.T) {
|
||||
baseline := map[string]any{
|
||||
"medrec_id": "00001234",
|
||||
"departemen_id": "LAB",
|
||||
"dokter_id": "DR01",
|
||||
"dokter_pengirim_id": "DR02",
|
||||
"jenis_pasien_id": "0",
|
||||
"periode_tarif_id": 10,
|
||||
"diagnosa_kerja": "Pemeriksaan laboratorium",
|
||||
}
|
||||
@@ -130,9 +212,14 @@ func TestRegisterLabValidationParity(t *testing.T) {
|
||||
message: "Dokter pengirim atau perujuk wajib diisi",
|
||||
},
|
||||
{
|
||||
name: "non-general patient",
|
||||
name: "missing company for guarantor",
|
||||
mutate: func(payload map[string]any) { payload["jenis_pasien_id"] = "1" },
|
||||
message: "Registrasi laboratorium API saat ini hanya mendukung pasien umum",
|
||||
message: "Perusahaan wajib diisi untuk pasien penjamin",
|
||||
},
|
||||
{
|
||||
name: "missing patient type",
|
||||
mutate: func(payload map[string]any) { delete(payload, "jenis_pasien_id") },
|
||||
message: "Jenis pasien wajib diisi",
|
||||
},
|
||||
{
|
||||
name: "invalid tariff period type",
|
||||
@@ -203,7 +290,7 @@ func TestRegisterLabRepositoryErrorsMatchLaravelEnvelope(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
const validBody = `{"medrec_id":"00001234","departemen_id":"LAB","dokter_id":"DR01","dokter_pengirim_id":"DR02","periode_tarif_id":10,"diagnosa_kerja":"Pemeriksaan laboratorium"}`
|
||||
const validBody = `{"medrec_id":"00001234","departemen_id":"LAB","dokter_id":"DR01","dokter_pengirim_id":"DR02","jenis_pasien_id":"0","periode_tarif_id":10,"diagnosa_kerja":"Pemeriksaan laboratorium"}`
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
handler := Handler{labRepo: &fakeLabRegistrationRepository{err: test.err}}
|
||||
|
||||
@@ -34,7 +34,7 @@ func (h Handler) GetLabResult(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
outcome, err := h.labResultRepo.GetLabResult(r.Context(), strings.ToUpper(labNumber))
|
||||
outcome, err := h.labResultRepository(r.Context()).GetLabResult(r.Context(), strings.ToUpper(labNumber))
|
||||
if err != nil {
|
||||
var validationErr repository.ValidationError
|
||||
if errors.As(err, &validationErr) {
|
||||
|
||||
@@ -21,7 +21,7 @@ func (h Handler) AddLabServices(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
outcome, err := h.labRepo.AddLabServices(r.Context(), r.PathValue("regId"), params)
|
||||
outcome, err := h.labRepository(r.Context()).AddLabServices(r.Context(), r.PathValue("regId"), params)
|
||||
if err != nil {
|
||||
if validationErr, ok := asValidationError(err); ok {
|
||||
writeLabResponse(w, http.StatusUnprocessableEntity, false, "validation_error", validationErr, nil)
|
||||
|
||||
@@ -58,7 +58,7 @@ func TestAddLabServicesValidPayloadAndResponseParity(t *testing.T) {
|
||||
if recorder.Code != http.StatusCreated {
|
||||
t.Fatalf("status = %d; body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
const expected = `{"success":true,"status":"services_added","message":"Layanan laboratorium berhasil ditambahkan","data":{"registration":{"RegID":"CPLAB2608180001","MEDRECID":"00001234","Tanggal":"2026-08-18 10:11:12","DepartemenID":"LAB","DokterID":"DR01","JenisPasienID":0,"JenisRegID":3,"PeriodeTarifID":10},"transaction":{"TrxDepartemenID":99,"TrxID":"CPLAB2608180001","GrandTotal":125000},"layanan":[],"added_layanan_ids":["6413"],"skipped_layanan_ids":["6414"]}}` + "\n"
|
||||
const expected = `{"success":true,"status":"services_added","message":"Layanan laboratorium berhasil ditambahkan","data":{"registration":{"RegID":"CPLAB2608180001","MEDRECID":"00001234","Tanggal":"2026-08-18 10:11:12","DepartemenID":"LAB","DokterID":"DR01","JenisPasienID":0,"JenisRegID":3,"PeriodeTarifID":10,"PerusahaanID":null,"AsuransiID":null,"ProviderID":null,"GroupTarifID":null,"NomerPolis":null},"transaction":{"TrxDepartemenID":99,"TrxID":"CPLAB2608180001","GrandTotal":125000},"layanan":[],"added_layanan_ids":["6413"],"skipped_layanan_ids":["6414"]}}` + "\n"
|
||||
if recorder.Body.String() != expected {
|
||||
t.Fatalf("body mismatch\n got: %s\nwant: %s", recorder.Body.String(), expected)
|
||||
}
|
||||
|
||||
72
internal/http/laporan_mcu.go
Normal file
72
internal/http/laporan_mcu.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"primaya-api/cpone-middleware/internal/repository"
|
||||
)
|
||||
|
||||
// ListLaporanMCU exposes the data formerly produced as mcu.xls by
|
||||
// docs/lap_mcu.php. The API keeps the same lower-case date parameters as the
|
||||
// PHP script and returns the spreadsheet rows as JSON data.
|
||||
func (h Handler) ListLaporanMCU(w http.ResponseWriter, r *http.Request) {
|
||||
tanggal, ok := firstQueryValue(r, "tanggal", "Tanggal")
|
||||
if !ok || strings.TrimSpace(tanggal) == "" {
|
||||
WriteJSON(w, http.StatusUnprocessableEntity, Response{
|
||||
Success: false, Message: "Parameter tanggal wajib diisi", Data: nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
tanggal2, ok := firstQueryValue(r, "tanggal2", "Tanggal2")
|
||||
if !ok || strings.TrimSpace(tanggal2) == "" {
|
||||
WriteJSON(w, http.StatusUnprocessableEntity, Response{
|
||||
Success: false, Message: "Parameter tanggal2 wajib diisi", Data: nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
start, err := parseReportDate(tanggal)
|
||||
if err != nil {
|
||||
WriteJSON(w, http.StatusUnprocessableEntity, Response{
|
||||
Success: false, Message: "Parameter tanggal harus format Y-m-d", Data: nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
end, err := parseReportDate(tanggal2)
|
||||
if err != nil {
|
||||
WriteJSON(w, http.StatusUnprocessableEntity, Response{
|
||||
Success: false, Message: "Parameter tanggal2 harus format Y-m-d", Data: nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
if end.Before(start) {
|
||||
WriteJSON(w, http.StatusUnprocessableEntity, Response{
|
||||
Success: false, Message: "Parameter tanggal2 tidak boleh lebih kecil dari tanggal", Data: nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := h.laporanMCURepository(r.Context()).ListLaporanMCU(r.Context(), repository.LaporanMCUParams{
|
||||
Tanggal: start.Format("2006-01-02"), Tanggal2: end.Format("2006-01-02"),
|
||||
})
|
||||
if err != nil {
|
||||
WriteJSON(w, http.StatusInternalServerError, Response{
|
||||
Success: false, Message: "internal server error", Data: nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
WriteJSON(w, http.StatusOK, Response{Success: true, Message: "ok", Data: rows})
|
||||
}
|
||||
|
||||
// GetLaporanMCU is an explicit alias for callers that use the Get naming
|
||||
// convention for report endpoints.
|
||||
func (h Handler) GetLaporanMCU(w http.ResponseWriter, r *http.Request) {
|
||||
h.ListLaporanMCU(w, r)
|
||||
}
|
||||
|
||||
func parseReportDate(value string) (time.Time, error) {
|
||||
return time.Parse("2006-01-02", strings.TrimSpace(value))
|
||||
}
|
||||
74
internal/http/laporan_mcu_test.go
Normal file
74
internal/http/laporan_mcu_test.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"primaya-api/cpone-middleware/internal/repository"
|
||||
)
|
||||
|
||||
type fakeLaporanMCURepository struct {
|
||||
rows []repository.LaporanMCURow
|
||||
err error
|
||||
params repository.LaporanMCUParams
|
||||
}
|
||||
|
||||
func (f *fakeLaporanMCURepository) ListLaporanMCU(_ context.Context, params repository.LaporanMCUParams) ([]repository.LaporanMCURow, error) {
|
||||
f.params = params
|
||||
return f.rows, f.err
|
||||
}
|
||||
|
||||
func TestListLaporanMCUResponseAndDateRange(t *testing.T) {
|
||||
regID := "MCU260900001"
|
||||
repo := &fakeLaporanMCURepository{rows: []repository.LaporanMCURow{{No: 1, RegID: ®ID}}}
|
||||
handler := Handler{laporanMCURepo: repo}
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/cpone/reports/mcu?tanggal=2026-09-01&tanggal2=2026-09-07", nil)
|
||||
|
||||
handler.ListLaporanMCU(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if repo.params.Tanggal != "2026-09-01" || repo.params.Tanggal2 != "2026-09-07" {
|
||||
t.Fatalf("params=%+v", repo.params)
|
||||
}
|
||||
for _, expected := range []string{`"success":true`, `"message":"ok"`, `"No":1`, `"RegID":"MCU260900001"`} {
|
||||
if !strings.Contains(recorder.Body.String(), expected) {
|
||||
t.Fatalf("response missing %s: %s", expected, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListLaporanMCUValidatesDateRange(t *testing.T) {
|
||||
repo := &fakeLaporanMCURepository{}
|
||||
handler := Handler{laporanMCURepo: repo}
|
||||
for target := range map[string]struct{}{
|
||||
"/api/cpone/reports/mcu": {},
|
||||
"/api/cpone/reports/mcu?tanggal=01-09-2026&tanggal2=2026-09-07": {},
|
||||
"/api/cpone/reports/mcu?tanggal=2026-09-08&tanggal2=2026-09-07": {},
|
||||
"/api/cpone/reports/mcu?tanggal=2026-09-01&tanggal2=not-a-date": {},
|
||||
} {
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.ListLaporanMCU(recorder, httptest.NewRequest(http.MethodGet, target, nil))
|
||||
if recorder.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("target=%s status=%d body=%s", target, recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
if repo.params != (repository.LaporanMCUParams{}) {
|
||||
t.Fatalf("repository should not be called for invalid dates: %+v", repo.params)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListLaporanMCUInternalError(t *testing.T) {
|
||||
handler := Handler{laporanMCURepo: &fakeLaporanMCURepository{err: errors.New("database unavailable")}}
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.ListLaporanMCU(recorder, httptest.NewRequest(http.MethodGet, "/api/cpone/reports/mcu?tanggal=2026-09-01&tanggal2=2026-09-01", nil))
|
||||
if recorder.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
83
internal/http/layanan.go
Normal file
83
internal/http/layanan.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"primaya-api/cpone-middleware/internal/repository"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultLayananPerPage = 15
|
||||
maxLayananPerPage = 1000
|
||||
)
|
||||
|
||||
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)
|
||||
perPage := layananPageSize(r)
|
||||
search := strings.TrimSpace(r.URL.Query().Get("search"))
|
||||
departemenID := strings.TrimSpace(r.URL.Query().Get("departemen_id"))
|
||||
page, err := h.layananRepository(r.Context()).ListLayanan(r.Context(), repository.LayananListParams{
|
||||
Page: pageNumber, PerPage: perPage, Search: search, DepartemenID: departemenID,
|
||||
})
|
||||
if err != nil {
|
||||
WriteJSON(w, http.StatusInternalServerError, Response{
|
||||
Success: false, Message: "internal server error", Data: nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
lastPage := (page.Total + perPage - 1) / perPage
|
||||
if lastPage < 1 {
|
||||
lastPage = 1
|
||||
}
|
||||
var from *int
|
||||
if len(page.Items) > 0 {
|
||||
value := (pageNumber-1)*perPage + 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: perPage,
|
||||
})
|
||||
}
|
||||
|
||||
func layananPageSize(r *http.Request) int {
|
||||
perPage, err := strconv.Atoi(r.URL.Query().Get("per_page"))
|
||||
if err != nil || perPage < 1 {
|
||||
return defaultLayananPerPage
|
||||
}
|
||||
if perPage > maxLayananPerPage {
|
||||
return maxLayananPerPage
|
||||
}
|
||||
return perPage
|
||||
}
|
||||
|
||||
func layananPageNumber(r *http.Request) int {
|
||||
page, err := strconv.Atoi(r.URL.Query().Get("page"))
|
||||
if err != nil || page < 1 {
|
||||
return 1
|
||||
}
|
||||
return page
|
||||
}
|
||||
97
internal/http/layanan_test.go
Normal file
97
internal/http/layanan_test.go
Normal file
@@ -0,0 +1,97 @@
|
||||
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, code, name := "6413", "DL", "DARAH LENGKAP"
|
||||
repo := &fakeLayananRepository{page: repository.LayananPage{
|
||||
Total: 16,
|
||||
Items: []repository.LayananDepartemen{{
|
||||
GroupLayananID: &group, Departemen: &department, LayananDepartemenID: 987,
|
||||
LayananID: &layananID, LayananCode: &code, NamaLayanan: &name,
|
||||
}},
|
||||
}}
|
||||
handler := Handler{layananRepo: repo}
|
||||
request := httptest.NewRequest(http.MethodGet, "http://localhost/api/cpone/layanan?page=1&search=tes&departemen_id=MCU&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 != defaultLayananPerPage || repo.params.Search != "tes" || repo.params.DepartemenID != "MCU" {
|
||||
t.Fatalf("params = %+v", repo.params)
|
||||
}
|
||||
for _, expected := range []string{
|
||||
`"GroupLayananID":"MCU"`, `"Departemen":"MEDICAL CHECK UP"`,
|
||||
`"LayananDepartemenID":987`, `"LayananID":"6413"`,
|
||||
`"NamaLayanan":"DARAH LENGKAP"`, `"LayananCode":"DL"`,
|
||||
`"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 != defaultLayananPerPage {
|
||||
t.Fatalf("target=%s status=%d params=%+v", target, recorder.Code, repo.params)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListLayananAcceptsBoundedPerPage(t *testing.T) {
|
||||
repo := &fakeLayananRepository{page: repository.LayananPage{Items: []repository.LayananDepartemen{}}}
|
||||
handler := Handler{layananRepo: repo}
|
||||
for target, expected := range map[string]int{
|
||||
"/api/cpone/layanan?per_page=250": 250,
|
||||
"/api/cpone/layanan?per_page=5000": maxLayananPerPage,
|
||||
} {
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.ListLayanan(recorder, httptest.NewRequest(http.MethodGet, target, nil))
|
||||
if recorder.Code != http.StatusOK || repo.params.PerPage != expected {
|
||||
t.Fatalf("target=%s status=%d per_page=%d", target, recorder.Code, repo.params.PerPage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -32,8 +32,9 @@ func (h Handler) StoreMedrec(w http.ResponseWriter, r *http.Request) {
|
||||
writeMedrecResponse(w, http.StatusUnprocessableEntity, false, "validation_error", message, nil)
|
||||
return
|
||||
}
|
||||
params.HospitalCode = h.hospitalCode
|
||||
|
||||
outcome, err := h.patientRepo.StoreMedrec(r.Context(), params)
|
||||
outcome, err := h.patientRepository(r.Context()).StoreMedrec(r.Context(), params)
|
||||
if err != nil {
|
||||
var validationErr repository.ValidationError
|
||||
if errors.As(err, &validationErr) {
|
||||
@@ -48,10 +49,6 @@ func (h Handler) StoreMedrec(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func validateMedrec(payload map[string]any) (repository.MedrecParams, string) {
|
||||
rs, message := requiredString(payload, "rs", 20, "Kode RS wajib diisi")
|
||||
if message != "" {
|
||||
return repository.MedrecParams{}, message
|
||||
}
|
||||
name, message := requiredString(payload, "nama", 255, "Nama pasien wajib diisi")
|
||||
if message != "" {
|
||||
return repository.MedrecParams{}, message
|
||||
@@ -155,6 +152,18 @@ func validateMedrec(payload map[string]any) (repository.MedrecParams, string) {
|
||||
if message != "" {
|
||||
return repository.MedrecParams{}, message
|
||||
}
|
||||
employeeID, message := optionalMedrecString(payload, "nik_karyawan", 50)
|
||||
if message != "" {
|
||||
return repository.MedrecParams{}, message
|
||||
}
|
||||
dependantID, message := optionalMedrecString(payload, "dependant_id", 4)
|
||||
if message != "" {
|
||||
return repository.MedrecParams{}, message
|
||||
}
|
||||
employeeName, message := optionalMedrecString(payload, "nama_karyawan", 100)
|
||||
if message != "" {
|
||||
return repository.MedrecParams{}, message
|
||||
}
|
||||
channel, channelPresent, message := optionalString(payload, "channel", 50)
|
||||
if message != "" {
|
||||
return repository.MedrecParams{}, message
|
||||
@@ -179,7 +188,6 @@ func validateMedrec(payload map[string]any) (repository.MedrecParams, string) {
|
||||
}
|
||||
|
||||
return repository.MedrecParams{
|
||||
HospitalCode: normalizeText(rs),
|
||||
Name: normalizeName(name),
|
||||
Gender: normalizeText(gender),
|
||||
DateOfBirth: birthDate,
|
||||
@@ -196,6 +204,9 @@ func validateMedrec(payload map[string]any) (repository.MedrecParams, string) {
|
||||
Village: strings.TrimSpace(village),
|
||||
PostalCode: strings.TrimSpace(postalCode),
|
||||
CompanyID: strings.TrimSpace(companyID),
|
||||
EmployeeID: strings.TrimSpace(employeeID),
|
||||
DependantID: strings.TrimSpace(dependantID),
|
||||
EmployeeName: strings.TrimSpace(employeeName),
|
||||
Channel: normalizeText(channel),
|
||||
}, ""
|
||||
}
|
||||
|
||||
@@ -48,9 +48,8 @@ func TestStoreMedrecValidPayloadAndCreatedResponseParity(t *testing.T) {
|
||||
IsNewPatient: true,
|
||||
},
|
||||
}}
|
||||
handler := Handler{patientRepo: repo}
|
||||
handler := Handler{patientRepo: repo, hospitalCode: "PRIMAYA"}
|
||||
body := `{
|
||||
"rs":" primaya ",
|
||||
"nama":" Budi Santos ",
|
||||
"jenis_kelamin":"l",
|
||||
"tgl_lahir":"1990-01-15",
|
||||
@@ -66,6 +65,10 @@ func TestStoreMedrecValidPayloadAndCreatedResponseParity(t *testing.T) {
|
||||
"kecamatan":" KECAMATAN CONTOH ",
|
||||
"kelurahan":" KELURAHAN CONTOH ",
|
||||
"kode_pos":" 12345 ",
|
||||
"perusahaan_id":" PE260700001 ",
|
||||
"nik_karyawan":" TSB2026002 ",
|
||||
"dependant_id":"D001",
|
||||
"nama_karyawan":" Budi Santos ",
|
||||
"channel":" cpone "
|
||||
}`
|
||||
|
||||
@@ -93,6 +96,9 @@ func TestStoreMedrecValidPayloadAndCreatedResponseParity(t *testing.T) {
|
||||
if repo.params.Email != "budi@example.com" || repo.params.PlaceOfBirth != "JAKARTA" || repo.params.Channel != "CPONE" {
|
||||
t.Fatalf("text normalization mismatch: %+v", repo.params)
|
||||
}
|
||||
if repo.params.CompanyID != "PE260700001" || repo.params.EmployeeID != "TSB2026002" || repo.params.DependantID != "D001" || repo.params.EmployeeName != "Budi Santos" {
|
||||
t.Fatalf("company patient fields mismatch: %+v", repo.params)
|
||||
}
|
||||
if repo.params.DateOfBirth.Format("2006-01-02") != "1990-01-15" {
|
||||
t.Fatalf("birth date mismatch: %s", repo.params.DateOfBirth)
|
||||
}
|
||||
@@ -100,7 +106,6 @@ func TestStoreMedrecValidPayloadAndCreatedResponseParity(t *testing.T) {
|
||||
|
||||
func TestStoreMedrecValidationParity(t *testing.T) {
|
||||
baseline := map[string]any{
|
||||
"rs": "PRIMAYA",
|
||||
"nama": "BUDI SANTOS",
|
||||
"jenis_kelamin": "L",
|
||||
"tgl_lahir": "1990-01-15",
|
||||
@@ -115,7 +120,6 @@ func TestStoreMedrecValidationParity(t *testing.T) {
|
||||
mutate func(map[string]any)
|
||||
message string
|
||||
}{
|
||||
{name: "missing rs", mutate: func(p map[string]any) { delete(p, "rs") }, message: "Kode RS wajib diisi"},
|
||||
{name: "missing name", mutate: func(p map[string]any) { delete(p, "nama") }, message: "Nama pasien wajib diisi"},
|
||||
{name: "invalid gender", mutate: func(p map[string]any) { p["jenis_kelamin"] = "M" }, message: "Jenis kelamin harus L atau P"},
|
||||
{name: "invalid date format", mutate: func(p map[string]any) { p["tgl_lahir"] = "15-01-1990" }, message: "Tanggal lahir harus format Y-m-d"},
|
||||
@@ -124,7 +128,7 @@ func TestStoreMedrecValidationParity(t *testing.T) {
|
||||
{name: "invalid ktp", mutate: func(p map[string]any) { p["nomer_sosial"] = "123" }, message: "NIK/KTP harus 16 digit angka"},
|
||||
{name: "missing phone", mutate: func(p map[string]any) { delete(p, "telepon") }, message: "Telepon atau ponsel wajib diisi"},
|
||||
{name: "invalid email", mutate: func(p map[string]any) { p["email"] = "invalid" }, message: "The email must be a valid email address."},
|
||||
{name: "long hospital code", mutate: func(p map[string]any) { p["rs"] = strings.Repeat("X", 21) }, message: "The rs must not be greater than 20 characters."},
|
||||
{name: "long dependant id", mutate: func(p map[string]any) { p["dependant_id"] = "D0001" }, message: "The dependant id must not be greater than 4 characters."},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
@@ -164,7 +168,7 @@ func TestStoreMedrecNikFallbackAndOutcomeForwarding(t *testing.T) {
|
||||
Data: data,
|
||||
}}
|
||||
handler := Handler{patientRepo: repo}
|
||||
body := `{"rs":"PRIMAYA","nama":"BUDI","jenis_kelamin":"P","tgl_lahir":"1990-01-15","nik":"3174011501900001","ponsel":"08123"}`
|
||||
body := `{"nama":"BUDI","jenis_kelamin":"P","tgl_lahir":"1990-01-15","nik":"3174011501900001","ponsel":"08123"}`
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/cpone/patients/medrec", strings.NewReader(body))
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.StoreMedrec(recorder, request)
|
||||
@@ -176,7 +180,7 @@ func TestStoreMedrecNikFallbackAndOutcomeForwarding(t *testing.T) {
|
||||
|
||||
func TestStoreMedrecRepositoryErrorParity(t *testing.T) {
|
||||
handler := Handler{patientRepo: &fakePatientRepository{err: errors.New("database unavailable")}}
|
||||
body := `{"rs":"PRIMAYA","nama":"BUDI","jenis_kelamin":"L","tgl_lahir":"1990-01-15","nomer_sosial":"3174011501900001","telepon":"08123"}`
|
||||
body := `{"nama":"BUDI","jenis_kelamin":"L","tgl_lahir":"1990-01-15","nomer_sosial":"3174011501900001","telepon":"08123"}`
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/cpone/patients/medrec", strings.NewReader(body))
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.StoreMedrec(recorder, request)
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"primaya-api/cpone-middleware/internal/databaseconfig"
|
||||
"primaya-api/cpone-middleware/internal/repository"
|
||||
)
|
||||
|
||||
type selectedRepositoryKey struct{}
|
||||
|
||||
type DatabaseResolver interface {
|
||||
Resolve(ctx context.Context, code string) (repository.MySQLLayananRepository, error)
|
||||
}
|
||||
|
||||
func BearerAuth(staticToken string, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if bearerToken(r.Header.Get("Authorization")) != staticToken {
|
||||
@@ -20,6 +31,41 @@ func BearerAuth(staticToken string, next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
func SelectDatabase(resolver DatabaseResolver, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
code := strings.TrimSpace(r.Header.Get("X-RS-Code"))
|
||||
if code == "" {
|
||||
code = strings.TrimSpace(r.URL.Query().Get("kode_rs"))
|
||||
}
|
||||
if code == "" {
|
||||
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)
|
||||
|
||||
repo, err := resolver.Resolve(r.Context(), code)
|
||||
if err != nil {
|
||||
if errors.Is(err, databaseconfig.ErrRSCodeMismatch) {
|
||||
WriteJSON(w, http.StatusForbidden, Response{Success: false,
|
||||
Message: "X-RS-Code tidak sesuai dengan instance CPONE", Data: nil})
|
||||
return
|
||||
}
|
||||
WriteJSON(w, http.StatusServiceUnavailable, Response{Success: false,
|
||||
Message: "Database HIS tidak dapat diakses", Data: nil})
|
||||
return
|
||||
}
|
||||
w.Header().Set("X-RS-Code", code)
|
||||
ctx := context.WithValue(r.Context(), selectedRepositoryKey{}, repo)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
func selectedRepository(ctx context.Context) (repository.MySQLLayananRepository, bool) {
|
||||
repo, ok := ctx.Value(selectedRepositoryKey{}).(repository.MySQLLayananRepository)
|
||||
return repo, ok
|
||||
}
|
||||
|
||||
func bearerToken(header string) string {
|
||||
const prefix = "Bearer "
|
||||
if !strings.HasPrefix(header, prefix) {
|
||||
|
||||
86
internal/http/middleware_database_test.go
Normal file
86
internal/http/middleware_database_test.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"primaya-api/cpone-middleware/internal/databaseconfig"
|
||||
"primaya-api/cpone-middleware/internal/repository"
|
||||
)
|
||||
|
||||
type fakeDatabaseResolver struct {
|
||||
resolvedCode string
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeDatabaseResolver) Resolve(_ context.Context, code string) (repository.MySQLLayananRepository, error) {
|
||||
f.resolvedCode = code
|
||||
return repository.MySQLLayananRepository{}, f.err
|
||||
}
|
||||
|
||||
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"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
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")
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
request := httptest.NewRequest(http.MethodGet, test.target, nil)
|
||||
request.Header.Set("X-RS-Code", test.header)
|
||||
recorder := httptest.NewRecorder()
|
||||
SelectDatabase(resolver, next).ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusNoContent || resolver.resolvedCode != test.want {
|
||||
t.Fatalf("status=%d code=%q, want status=204 code=%q", recorder.Code, resolver.resolvedCode, test.want)
|
||||
}
|
||||
if recorder.Header().Get("X-RS-Code") != test.want {
|
||||
t.Fatalf("response X-RS-Code = %q", recorder.Header().Get("X-RS-Code"))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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 TestSelectDatabaseRejectsWrongInstanceCode(t *testing.T) {
|
||||
resolver := &fakeDatabaseResolver{err: databaseconfig.ErrRSCodeMismatch}
|
||||
request := httptest.NewRequest(http.MethodGet, "/resource?kode_rs=unknown", 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.StatusForbidden {
|
||||
t.Fatalf("status = %d, body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
|
||||
resolver.err = errors.New("connection refused")
|
||||
recorder = httptest.NewRecorder()
|
||||
SelectDatabase(resolver, http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})).ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status = %d, body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,7 @@ func (h Handler) ListPaket(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
page, err := h.paketRepo.ListPaket(r.Context(), params)
|
||||
page, err := h.paketRepository(r.Context()).ListPaket(r.Context(), params)
|
||||
if err != nil {
|
||||
WriteJSON(w, http.StatusInternalServerError, Response{
|
||||
Success: false, Message: "internal server error", Data: nil,
|
||||
@@ -76,7 +76,7 @@ func (h Handler) ListPaket(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (h Handler) GetPaket(w http.ResponseWriter, r *http.Request) {
|
||||
id := strings.TrimSpace(r.PathValue("id"))
|
||||
paket, err := h.paketRepo.GetPaket(r.Context(), id)
|
||||
paket, err := h.paketRepository(r.Context()).GetPaket(r.Context(), id)
|
||||
if err != nil {
|
||||
WriteJSON(w, http.StatusInternalServerError, Response{
|
||||
Success: false, Message: "internal server error", Data: nil,
|
||||
@@ -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) {
|
||||
|
||||
378
internal/http/paket_create.go
Normal file
378
internal/http/paket_create.go
Normal file
@@ -0,0 +1,378 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"primaya-api/cpone-middleware/internal/repository"
|
||||
)
|
||||
|
||||
type PaketCreateResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
Data *PaketCreateResponseData `json:"data"`
|
||||
}
|
||||
|
||||
type PaketCreateResponseData struct {
|
||||
PaketID string `json:"paket_id"`
|
||||
Nama string `json:"nama"`
|
||||
TotalHarga string `json:"total_harga"`
|
||||
TotalDiskon string `json:"total_diskon"`
|
||||
JumlahLayanan int `json:"jumlah_layanan"`
|
||||
}
|
||||
|
||||
func (h Handler) CreatePaket(w http.ResponseWriter, r *http.Request) {
|
||||
payload, err := decodeJSONObject(r)
|
||||
if err != nil {
|
||||
writePaketCreateError(w, http.StatusUnprocessableEntity, "Payload harus berupa object JSON")
|
||||
return
|
||||
}
|
||||
params, message := validateCreatePaket(payload)
|
||||
if message != "" {
|
||||
writePaketCreateError(w, http.StatusUnprocessableEntity, message)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.paketRepository(r.Context()).CreatePaket(r.Context(), params)
|
||||
if err != nil {
|
||||
log.Printf("create paket failed: %v", err)
|
||||
if validationMessage, ok := asValidationError(err); ok {
|
||||
writePaketCreateError(w, http.StatusUnprocessableEntity, validationMessage)
|
||||
return
|
||||
}
|
||||
WriteJSON(w, http.StatusInternalServerError, Response{Success: false, Message: "internal server error", Data: nil})
|
||||
return
|
||||
}
|
||||
|
||||
WriteJSON(w, http.StatusCreated, PaketCreateResponse{
|
||||
Success: true,
|
||||
Status: "created",
|
||||
Message: "Paket berhasil dibuat",
|
||||
Data: &PaketCreateResponseData{
|
||||
PaketID: result.PaketID, Nama: result.Nama,
|
||||
TotalHarga: result.TotalHarga, TotalDiskon: result.TotalDiskon,
|
||||
JumlahLayanan: result.JumlahLayanan,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func validateCreatePaket(payload map[string]any) (repository.CreatePaketParams, string) {
|
||||
name, message := requiredString(payload, "nama", 255, "Nama paket wajib diisi")
|
||||
if message != "" {
|
||||
return repository.CreatePaketParams{}, message
|
||||
}
|
||||
typeID, message := requiredString(payload, "jenis_paket", 50, "Jenis paket wajib diisi")
|
||||
if message != "" {
|
||||
return repository.CreatePaketParams{}, message
|
||||
}
|
||||
|
||||
rawatInap, message := optionalPaketBoolean(payload, "rawat_inap")
|
||||
if message != "" {
|
||||
return repository.CreatePaketParams{}, message
|
||||
}
|
||||
rawatJalan, message := optionalPaketBoolean(payload, "rawat_jalan")
|
||||
if message != "" {
|
||||
return repository.CreatePaketParams{}, message
|
||||
}
|
||||
if !rawatInap && !rawatJalan {
|
||||
return repository.CreatePaketParams{}, "Rawat inap atau rawat jalan minimal salah satu harus aktif"
|
||||
}
|
||||
multiVisit, message := optionalPaketBoolean(payload, "multi_visit")
|
||||
if message != "" {
|
||||
return repository.CreatePaketParams{}, message
|
||||
}
|
||||
|
||||
startDate, message := optionalPaketDate(payload, "tanggal_mulai")
|
||||
if message != "" {
|
||||
return repository.CreatePaketParams{}, message
|
||||
}
|
||||
endDate, message := optionalPaketDate(payload, "tanggal_selesai")
|
||||
if message != "" {
|
||||
return repository.CreatePaketParams{}, message
|
||||
}
|
||||
if startDate != "" && endDate != "" && endDate < startDate {
|
||||
return repository.CreatePaketParams{}, "Tanggal selesai harus setelah atau sama dengan tanggal mulai"
|
||||
}
|
||||
|
||||
classID, _, message := optionalString(payload, "kelas_id", 50)
|
||||
if message != "" {
|
||||
return repository.CreatePaketParams{}, message
|
||||
}
|
||||
if strings.TrimSpace(classID) == "" {
|
||||
classID = "Default"
|
||||
}
|
||||
headerID, _, message := optionalString(payload, "header_paket_id", 50)
|
||||
if message != "" {
|
||||
return repository.CreatePaketParams{}, message
|
||||
}
|
||||
notes, _, message := optionalString(payload, "keterangan", 500)
|
||||
if message != "" {
|
||||
return repository.CreatePaketParams{}, message
|
||||
}
|
||||
channel, present, message := optionalString(payload, "channel", 50)
|
||||
if message != "" {
|
||||
return repository.CreatePaketParams{}, message
|
||||
}
|
||||
if !present || strings.TrimSpace(channel) == "" {
|
||||
channel = "CPONE"
|
||||
}
|
||||
|
||||
days := int64(1)
|
||||
if value, present := nullableValue(payload, "jumlah_hari"); present {
|
||||
days, message = parsePaketInteger(value, "jumlah_hari", "Jumlah hari paket harus berupa angka")
|
||||
if message != "" {
|
||||
return repository.CreatePaketParams{}, message
|
||||
}
|
||||
if days < 1 {
|
||||
return repository.CreatePaketParams{}, "Jumlah hari paket minimal 1"
|
||||
}
|
||||
}
|
||||
|
||||
rawServices, exists := payload["layanan"]
|
||||
if !exists || isLaravelEmpty(rawServices) {
|
||||
return repository.CreatePaketParams{}, "Layanan wajib diisi"
|
||||
}
|
||||
services, ok := rawServices.([]any)
|
||||
if !ok || len(services) == 0 {
|
||||
return repository.CreatePaketParams{}, "Layanan harus berupa array dan minimal memiliki satu data"
|
||||
}
|
||||
result := make([]repository.CreatePaketLayananParams, 0, len(services))
|
||||
seen := make(map[string]struct{}, len(services))
|
||||
for index, rawService := range services {
|
||||
item, ok := rawService.(map[string]any)
|
||||
if !ok {
|
||||
return repository.CreatePaketParams{}, nestedCreatePaketMessage(index, "layanan harus berupa object")
|
||||
}
|
||||
serviceID, message := requiredNestedString(item, "layanan_id", index, 50, "Layanan wajib diisi")
|
||||
if message != "" {
|
||||
return repository.CreatePaketParams{}, message
|
||||
}
|
||||
departmentID, message := requiredNestedString(item, "departemen_id", index, 50, "Departemen wajib diisi")
|
||||
if message != "" {
|
||||
return repository.CreatePaketParams{}, message
|
||||
}
|
||||
serviceID = strings.TrimSpace(serviceID)
|
||||
departmentID = strings.TrimSpace(departmentID)
|
||||
if _, exists := seen[serviceID]; exists {
|
||||
return repository.CreatePaketParams{}, nestedCreatePaketMessage(index, "layanan_id tidak boleh duplikat dalam satu paket")
|
||||
}
|
||||
seen[serviceID] = struct{}{}
|
||||
|
||||
quantity, message := optionalPaketNestedInteger(item, "jumlah", index, 1)
|
||||
if message != "" {
|
||||
return repository.CreatePaketParams{}, message
|
||||
}
|
||||
if quantity < 1 {
|
||||
return repository.CreatePaketParams{}, nestedCreatePaketMessage(index, "jumlah minimal 1")
|
||||
}
|
||||
serviceDays, message := optionalPaketNestedInteger(item, "jumlah_hari", index, 1)
|
||||
if message != "" {
|
||||
return repository.CreatePaketParams{}, message
|
||||
}
|
||||
if serviceDays < 1 {
|
||||
return repository.CreatePaketParams{}, nestedCreatePaketMessage(index, "jumlah_hari minimal 1")
|
||||
}
|
||||
|
||||
harga, message := requiredPaketMoney(item, "harga", index, "Harga wajib diisi")
|
||||
if message != "" {
|
||||
return repository.CreatePaketParams{}, message
|
||||
}
|
||||
discountType, _, message := optionalNestedString(item, "tipe_diskon", index, 30)
|
||||
if message != "" {
|
||||
return repository.CreatePaketParams{}, message
|
||||
}
|
||||
if strings.TrimSpace(discountType) == "" {
|
||||
discountType = "Percentage"
|
||||
}
|
||||
discountType, message = normalizePaketType(discountType, "tipe_diskon", index)
|
||||
if message != "" {
|
||||
return repository.CreatePaketParams{}, message
|
||||
}
|
||||
discount, message := optionalPaketMoney(item, "diskon", index, 0)
|
||||
if message != "" {
|
||||
return repository.CreatePaketParams{}, message
|
||||
}
|
||||
if discountType == "Percentage" && discount > 10000 {
|
||||
return repository.CreatePaketParams{}, nestedCreatePaketMessage(index, "diskon percentage tidak boleh lebih dari 100")
|
||||
}
|
||||
if discountType == "Absolute" && discount > harga {
|
||||
return repository.CreatePaketParams{}, nestedCreatePaketMessage(index, "diskon absolute tidak boleh lebih besar dari harga")
|
||||
}
|
||||
|
||||
shareType, _, message := optionalNestedString(item, "tipe_share", index, 30)
|
||||
if message != "" {
|
||||
return repository.CreatePaketParams{}, message
|
||||
}
|
||||
if strings.TrimSpace(shareType) == "" {
|
||||
shareType = "Percentage"
|
||||
}
|
||||
shareType, message = normalizePaketType(shareType, "tipe_share", index)
|
||||
if message != "" {
|
||||
return repository.CreatePaketParams{}, message
|
||||
}
|
||||
shares := make([]int64, 4)
|
||||
shareKeys := []string{"share_rs", "share_dokter", "share_external", "share_lain"}
|
||||
for shareIndex, key := range shareKeys {
|
||||
shares[shareIndex], message = optionalPaketMoney(item, key, index, 0)
|
||||
if message != "" {
|
||||
return repository.CreatePaketParams{}, message
|
||||
}
|
||||
}
|
||||
if shareType == "Percentage" {
|
||||
shareTotal := shares[0] + shares[1] + shares[2] + shares[3]
|
||||
for _, share := range shares {
|
||||
if share < 0 || share > 10000 {
|
||||
return repository.CreatePaketParams{}, nestedCreatePaketMessage(index, "share percentage harus antara 0 dan 100")
|
||||
}
|
||||
}
|
||||
if shareTotal != 10000 {
|
||||
return repository.CreatePaketParams{}, nestedCreatePaketMessage(index, "total share percentage harus 100")
|
||||
}
|
||||
} else if math.Abs(float64(shares[0]+shares[1]+shares[2]+shares[3]-harga)) > 1 {
|
||||
return repository.CreatePaketParams{}, nestedCreatePaketMessage(index, "total share absolute harus sama dengan harga")
|
||||
}
|
||||
|
||||
result = append(result, repository.CreatePaketLayananParams{
|
||||
LayananID: serviceID, DepartemenID: departmentID, Jumlah: quantity, JumlahHari: serviceDays,
|
||||
HargaCents: harga, TipeDiskon: discountType, DiskonCents: discount, TipeShare: shareType,
|
||||
ShareRSCents: shares[0], ShareDokterCents: shares[1], ShareExternalCents: shares[2], ShareLainCents: shares[3],
|
||||
})
|
||||
}
|
||||
|
||||
return repository.CreatePaketParams{
|
||||
Nama: strings.TrimSpace(name), JenisPaket: strings.ToUpper(strings.TrimSpace(typeID)),
|
||||
RawatInap: rawatInap, RawatJalan: rawatJalan, MultiVisit: multiVisit,
|
||||
TanggalMulai: startDate, TanggalSelesai: endDate, KelasID: strings.TrimSpace(classID),
|
||||
HeaderPaketID: strings.TrimSpace(headerID), Keterangan: strings.TrimSpace(notes), JumlahHari: days,
|
||||
Channel: strings.ToUpper(strings.TrimSpace(channel)), Layanan: result,
|
||||
}, ""
|
||||
}
|
||||
|
||||
func optionalPaketBoolean(payload map[string]any, key string) (bool, string) {
|
||||
value, present := nullableValue(payload, key)
|
||||
if !present {
|
||||
return false, ""
|
||||
}
|
||||
parsed, ok := parseLaravelBoolean(value)
|
||||
if !ok {
|
||||
return false, fmt.Sprintf("The %s field must be true or false.", strings.ReplaceAll(key, "_", " "))
|
||||
}
|
||||
return parsed, ""
|
||||
}
|
||||
|
||||
func optionalPaketDate(payload map[string]any, key string) (string, string) {
|
||||
value, present := nullableValue(payload, key)
|
||||
if !present {
|
||||
return "", ""
|
||||
}
|
||||
date, ok := value.(string)
|
||||
if !ok || !validDateFormat(date, time.Local) {
|
||||
return "", fmt.Sprintf("The %s does not match the format Y-m-d.", strings.ReplaceAll(key, "_", " "))
|
||||
}
|
||||
return date, ""
|
||||
}
|
||||
|
||||
func parsePaketInteger(value any, key, message string) (int64, string) {
|
||||
parsed, ok := parseInteger(value)
|
||||
if !ok {
|
||||
return 0, message
|
||||
}
|
||||
return parsed, ""
|
||||
}
|
||||
|
||||
func optionalPaketNestedInteger(item map[string]any, key string, index int, fallback int64) (int64, string) {
|
||||
value, present := nullableValue(item, key)
|
||||
if !present {
|
||||
return fallback, ""
|
||||
}
|
||||
parsed, ok := parseInteger(value)
|
||||
if !ok {
|
||||
return 0, nestedCreatePaketMessage(index, key+" harus berupa angka")
|
||||
}
|
||||
return parsed, ""
|
||||
}
|
||||
|
||||
func requiredPaketMoney(item map[string]any, key string, index int, requiredMessage string) (int64, string) {
|
||||
value, present := nullableValue(item, key)
|
||||
if !present {
|
||||
return 0, nestedCreatePaketMessage(index, requiredMessage)
|
||||
}
|
||||
return parsePaketMoney(value, nestedCreatePaketMessage(index, key+" harus berupa nominal desimal"))
|
||||
}
|
||||
|
||||
func optionalPaketMoney(item map[string]any, key string, index int, fallback int64) (int64, string) {
|
||||
value, present := nullableValue(item, key)
|
||||
if !present {
|
||||
return fallback, ""
|
||||
}
|
||||
return parsePaketMoney(value, nestedCreatePaketMessage(index, key+" harus berupa nominal desimal"))
|
||||
}
|
||||
|
||||
func parsePaketMoney(value any, message string) (int64, string) {
|
||||
var text string
|
||||
switch typed := value.(type) {
|
||||
case json.Number:
|
||||
text = typed.String()
|
||||
case string:
|
||||
text = strings.TrimSpace(typed)
|
||||
default:
|
||||
return 0, message
|
||||
}
|
||||
if text == "" || strings.HasPrefix(text, "-") || strings.HasPrefix(text, "+") {
|
||||
return 0, message
|
||||
}
|
||||
parts := strings.Split(text, ".")
|
||||
if len(parts) > 2 || parts[0] == "" || len(parts[0]) > 16 || (len(parts) == 2 && len(parts[1]) > 2) {
|
||||
return 0, message
|
||||
}
|
||||
if _, err := strconv.ParseUint(parts[0], 10, 64); err != nil {
|
||||
return 0, message
|
||||
}
|
||||
frac := ""
|
||||
if len(parts) == 2 {
|
||||
frac = parts[1]
|
||||
}
|
||||
for len(frac) < 2 {
|
||||
frac += "0"
|
||||
}
|
||||
fracValue := int64(0)
|
||||
if frac != "" {
|
||||
parsed, err := strconv.ParseInt(frac, 10, 64)
|
||||
if err != nil {
|
||||
return 0, message
|
||||
}
|
||||
fracValue = parsed
|
||||
}
|
||||
whole, err := strconv.ParseInt(parts[0], 10, 64)
|
||||
if err != nil || whole > (math.MaxInt64-fracValue)/100 {
|
||||
return 0, message
|
||||
}
|
||||
return whole*100 + fracValue, ""
|
||||
}
|
||||
|
||||
func normalizePaketType(value, key string, index int) (string, string) {
|
||||
value = strings.TrimSpace(value)
|
||||
switch strings.ToLower(value) {
|
||||
case "percentage":
|
||||
return "Percentage", ""
|
||||
case "absolute":
|
||||
return "Absolute", ""
|
||||
default:
|
||||
return "", nestedCreatePaketMessage(index, key+" harus Percentage atau Absolute")
|
||||
}
|
||||
}
|
||||
|
||||
func nestedCreatePaketMessage(index int, message string) string {
|
||||
return fmt.Sprintf("The layanan.%d %s.", index, message)
|
||||
}
|
||||
|
||||
func writePaketCreateError(w http.ResponseWriter, statusCode int, message string) {
|
||||
WriteJSON(w, statusCode, PaketCreateResponse{Success: false, Status: "validation_error", Message: message, Data: nil})
|
||||
}
|
||||
128
internal/http/paket_create_test.go
Normal file
128
internal/http/paket_create_test.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"primaya-api/cpone-middleware/internal/repository"
|
||||
)
|
||||
|
||||
const validCreatePaketJSON = `{
|
||||
"nama":"PAKET MCU CPONE TES 2",
|
||||
"jenis_paket":"MCU",
|
||||
"rawat_inap":false,
|
||||
"rawat_jalan":true,
|
||||
"multi_visit":false,
|
||||
"tanggal_mulai":"2026-09-01",
|
||||
"tanggal_selesai":"2026-09-30",
|
||||
"kelas_id":"Default",
|
||||
"layanan":[{
|
||||
"layanan_id":"1200",
|
||||
"departemen_id":"MED-PNJ-LAB",
|
||||
"jumlah":1,
|
||||
"jumlah_hari":1,
|
||||
"harga":"130000.00",
|
||||
"tipe_diskon":"Percentage",
|
||||
"diskon":"0.00",
|
||||
"tipe_share":"Absolute",
|
||||
"share_rs":"65000.00",
|
||||
"share_dokter":"0.00",
|
||||
"share_external":"65000.00",
|
||||
"share_lain":"0.00"
|
||||
}]
|
||||
}`
|
||||
|
||||
func TestCreatePaketResponseAndDecimalParsing(t *testing.T) {
|
||||
repo := &fakePaketRepository{create: repository.CreatePaketResult{
|
||||
PaketID: "DISP-260900022", Nama: "PAKET MCU CPONE TES 2",
|
||||
TotalHarga: "130000.00", TotalDiskon: "0.00", JumlahLayanan: 1,
|
||||
}}
|
||||
handler := Handler{paketRepo: repo}
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/cpone/paket", strings.NewReader(validCreatePaketJSON))
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler.CreatePaket(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusCreated {
|
||||
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if len(repo.createParams.Layanan) != 1 || repo.createParams.Layanan[0].HargaCents != 13000000 || repo.createParams.Layanan[0].ShareExternalCents != 6500000 {
|
||||
t.Fatalf("unexpected parsed params: %+v", repo.createParams)
|
||||
}
|
||||
const expected = `{"success":true,"status":"created","message":"Paket berhasil dibuat","data":{"paket_id":"DISP-260900022","nama":"PAKET MCU CPONE TES 2","total_harga":"130000.00","total_diskon":"0.00","jumlah_layanan":1}}` + "\n"
|
||||
if recorder.Body.String() != expected {
|
||||
t.Fatalf("body mismatch\n got: %s\nwant: %s", recorder.Body.String(), expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatePaketValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(map[string]any)
|
||||
message string
|
||||
valid bool
|
||||
}{
|
||||
{
|
||||
name: "requires care type",
|
||||
mutate: func(payload map[string]any) {
|
||||
payload["rawat_jalan"] = false
|
||||
},
|
||||
message: "Rawat inap atau rawat jalan minimal salah satu harus aktif",
|
||||
},
|
||||
{
|
||||
name: "rejects percentage discount above 100",
|
||||
mutate: func(payload map[string]any) {
|
||||
service := payload["layanan"].([]any)[0].(map[string]any)
|
||||
service["diskon"] = "100.01"
|
||||
},
|
||||
message: "The layanan.0 diskon percentage tidak boleh lebih dari 100.",
|
||||
},
|
||||
{
|
||||
name: "rejects absolute share mismatch",
|
||||
mutate: func(payload map[string]any) {
|
||||
service := payload["layanan"].([]any)[0].(map[string]any)
|
||||
service["share_external"] = "64000.00"
|
||||
},
|
||||
message: "The layanan.0 total share absolute harus sama dengan harga.",
|
||||
},
|
||||
{
|
||||
name: "allows one cent absolute share rounding difference",
|
||||
mutate: func(payload map[string]any) {
|
||||
service := payload["layanan"].([]any)[0].(map[string]any)
|
||||
service["share_external"] = "64999.99"
|
||||
},
|
||||
message: "",
|
||||
valid: true,
|
||||
},
|
||||
{
|
||||
name: "rejects duplicate service",
|
||||
mutate: func(payload map[string]any) {
|
||||
services := payload["layanan"].([]any)
|
||||
services = append(services, services[0])
|
||||
payload["layanan"] = services
|
||||
},
|
||||
message: "The layanan.1 layanan_id tidak boleh duplikat dalam satu paket.",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var payload map[string]any
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/cpone/paket", strings.NewReader(validCreatePaketJSON))
|
||||
payload, _ = decodeJSONObject(request)
|
||||
test.mutate(payload)
|
||||
params, message := validateCreatePaket(payload)
|
||||
if test.valid {
|
||||
if params.Nama == "" || message != "" {
|
||||
t.Fatalf("params=%+v message=%q want valid payload", params, message)
|
||||
}
|
||||
return
|
||||
}
|
||||
if params.Nama != "" || message != test.message {
|
||||
t.Fatalf("params=%+v message=%q want=%q", params, message, test.message)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -12,11 +12,13 @@ import (
|
||||
)
|
||||
|
||||
type fakePaketRepository struct {
|
||||
page repository.PaketPage
|
||||
detail *repository.PaketDetail
|
||||
err error
|
||||
listParams repository.PaketListParams
|
||||
detailID string
|
||||
page repository.PaketPage
|
||||
detail *repository.PaketDetail
|
||||
create repository.CreatePaketResult
|
||||
err error
|
||||
listParams repository.PaketListParams
|
||||
detailID string
|
||||
createParams repository.CreatePaketParams
|
||||
}
|
||||
|
||||
func (f *fakePaketRepository) ListPaket(_ context.Context, params repository.PaketListParams) (repository.PaketPage, error) {
|
||||
@@ -29,6 +31,11 @@ func (f *fakePaketRepository) GetPaket(_ context.Context, id string) (*repositor
|
||||
return f.detail, f.err
|
||||
}
|
||||
|
||||
func (f *fakePaketRepository) CreatePaket(_ context.Context, params repository.CreatePaketParams) (repository.CreatePaketResult, error) {
|
||||
f.createParams = params
|
||||
return f.create, f.err
|
||||
}
|
||||
|
||||
func TestListPaketResponseParity(t *testing.T) {
|
||||
kodeCorp := "ANP"
|
||||
jenisPaket := "Medical Check Up"
|
||||
@@ -92,13 +99,18 @@ func TestListPaketValidationParity(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGetPaketResponseAndNotFoundParity(t *testing.T) {
|
||||
keteranganPeriode := "Tarif Dasar 2026"
|
||||
detail := &repository.PaketDetail{
|
||||
PaketID: "DISP-260800002", Nama: "Test paket", JenisPaket: "DIA",
|
||||
RI: 1, TglDari: "2026-08-06", TglSampai: "2026-08-31", KelasID: "KelasII",
|
||||
JumlahHari: 1, TotalHarga: "1550913.00", StatusID: 2, LoginBuat: "API_CRM",
|
||||
TanggalBuat: "2026-08-05T10:05:03.000000Z", LoginEdit: "API_CRM",
|
||||
TanggalEdit: "2026-08-19T06:05:20.000000Z", NA: "N",
|
||||
Layanan: []repository.PaketLayanan{{HargaDasar: "100,000"}},
|
||||
PeriodeTarif: &repository.PaketPeriodeTarif{
|
||||
PeriodeTarifID: 42, Keterangan: &keteranganPeriode,
|
||||
TanggalBerlaku: "2026-01-01", TanggalBerakhir: "2026-12-31",
|
||||
},
|
||||
Layanan: []repository.PaketLayanan{{HargaDasar: "100,000", HargaDasarSource: "TARIF_PERIODE"}},
|
||||
Operasi: []repository.PaketOperasi{{HargaDasar: "250,000.00"}},
|
||||
Item: []repository.PaketItem{{HargaDasar: "50,000.00"}},
|
||||
Kamar: []repository.PaketKamar{{HargaDasar: "200,000.00"}},
|
||||
@@ -113,7 +125,9 @@ func TestGetPaketResponseAndNotFoundParity(t *testing.T) {
|
||||
t.Fatalf("status=%d id=%q body=%s", recorder.Code, repo.detailID, recorder.Body.String())
|
||||
}
|
||||
for _, expected := range []string{
|
||||
`"PeriodeTarif":{"PeriodeTarifID":42,"Keterangan":"Tarif Dasar 2026","TanggalBerlaku":"2026-01-01","TanggalBerakhir":"2026-12-31"}`,
|
||||
`"HargaDasar":"100,000"`,
|
||||
`"HargaDasarSource":"TARIF_PERIODE"`,
|
||||
`"HargaDasar":"250,000.00"`,
|
||||
`"HargaDasar":"50,000.00"`,
|
||||
`"HargaDasar":"200,000.00"`,
|
||||
|
||||
29
internal/http/periode_tarif.go
Normal file
29
internal/http/periode_tarif.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"primaya-api/cpone-middleware/internal/repository"
|
||||
)
|
||||
|
||||
func (h Handler) ListPeriodeTarif(w http.ResponseWriter, r *http.Request) {
|
||||
items, err := h.periodeTarifRepository(r.Context()).ListPeriodeTarif(r.Context())
|
||||
if err != nil {
|
||||
WriteJSON(w, http.StatusInternalServerError, Response{
|
||||
Success: false,
|
||||
Message: "internal server error",
|
||||
Data: nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if items == nil {
|
||||
items = []repository.PeriodeTarif{}
|
||||
}
|
||||
|
||||
WriteJSON(w, http.StatusOK, Response{
|
||||
Success: true,
|
||||
Message: "ok",
|
||||
Data: items,
|
||||
})
|
||||
}
|
||||
118
internal/http/periode_tarif_detail.go
Normal file
118
internal/http/periode_tarif_detail.go
Normal file
@@ -0,0 +1,118 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"primaya-api/cpone-middleware/internal/repository"
|
||||
)
|
||||
|
||||
const periodeTarifLayananPerPage = 20
|
||||
|
||||
type PeriodeTarifLayananPaginationResponse struct {
|
||||
CurrentPage int `json:"current_page"`
|
||||
Data []repository.PeriodeTarifLayanan `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) ListPeriodeTarifLayanan(w http.ResponseWriter, r *http.Request) {
|
||||
rawID := strings.TrimSpace(r.PathValue("periodeTarifID"))
|
||||
if rawID == "" {
|
||||
rawID = strings.TrimSpace(r.PathValue("PeriodeTarifID"))
|
||||
}
|
||||
periodeTarifID, err := strconv.ParseInt(rawID, 10, 64)
|
||||
if err != nil || periodeTarifID < 1 {
|
||||
WriteJSON(w, http.StatusUnprocessableEntity, Response{
|
||||
Success: false,
|
||||
Message: "PeriodeTarifID harus berupa angka positif",
|
||||
Data: nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
pageNumber := periodeTarifLayananPageNumber(r)
|
||||
search := strings.TrimSpace(r.URL.Query().Get("search"))
|
||||
page, err := h.periodeTarifDetailRepository(r.Context()).ListPeriodeTarifLayanan(r.Context(), repository.PeriodeTarifLayananListParams{
|
||||
PeriodeTarifID: periodeTarifID,
|
||||
Page: pageNumber,
|
||||
PerPage: periodeTarifLayananPerPage,
|
||||
Search: search,
|
||||
})
|
||||
if errors.Is(err, repository.ErrTarifTableNotFound) {
|
||||
WriteJSON(w, http.StatusNotFound, Response{
|
||||
Success: false,
|
||||
Message: "Tabel tarif tidak ditemukan: harga_layanan_" + strconv.FormatInt(periodeTarifID, 10),
|
||||
Data: nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
WriteJSON(w, http.StatusInternalServerError, Response{
|
||||
Success: false,
|
||||
Message: "internal server error",
|
||||
Data: nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if page.Items == nil {
|
||||
page.Items = []repository.PeriodeTarifLayanan{}
|
||||
}
|
||||
|
||||
lastPage := (page.Total + periodeTarifLayananPerPage - 1) / periodeTarifLayananPerPage
|
||||
if lastPage < 1 {
|
||||
lastPage = 1
|
||||
}
|
||||
var from *int
|
||||
if len(page.Items) > 0 {
|
||||
value := (pageNumber-1)*periodeTarifLayananPerPage + 1
|
||||
from = &value
|
||||
}
|
||||
var nextPageURL *string
|
||||
if pageNumber < lastPage {
|
||||
value := periodeTarifLayananPageURL(r, pageNumber+1)
|
||||
nextPageURL = &value
|
||||
}
|
||||
|
||||
WriteJSON(w, http.StatusOK, PeriodeTarifLayananPaginationResponse{
|
||||
CurrentPage: pageNumber, Data: page.Items, FirstPageURL: "", From: from,
|
||||
LastPage: lastPage, LastPageURL: "", Links: map[string]any{},
|
||||
NextPageURL: nextPageURL, Total: page.Total, PerPage: periodeTarifLayananPerPage,
|
||||
})
|
||||
}
|
||||
|
||||
func periodeTarifLayananPageNumber(r *http.Request) int {
|
||||
page, err := strconv.Atoi(r.URL.Query().Get("page"))
|
||||
if err != nil || page < 1 {
|
||||
return 1
|
||||
}
|
||||
return page
|
||||
}
|
||||
|
||||
func periodeTarifLayananPageURL(r *http.Request, page int) string {
|
||||
scheme := "http"
|
||||
if r.TLS != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
if forwarded := r.Header.Get("X-Forwarded-Proto"); forwarded != "" {
|
||||
scheme = strings.TrimSpace(strings.Split(forwarded, ",")[0])
|
||||
}
|
||||
host := r.Host
|
||||
if host == "" {
|
||||
host = "localhost"
|
||||
}
|
||||
query := r.URL.Query()
|
||||
query.Set("page", strconv.Itoa(page))
|
||||
query.Set("per_page", strconv.Itoa(periodeTarifLayananPerPage))
|
||||
return (&url.URL{Scheme: scheme, Host: host, Path: r.URL.Path, RawQuery: query.Encode()}).String()
|
||||
}
|
||||
113
internal/http/periode_tarif_detail_test.go
Normal file
113
internal/http/periode_tarif_detail_test.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"primaya-api/cpone-middleware/internal/repository"
|
||||
)
|
||||
|
||||
type fakePeriodeTarifDetailRepository struct {
|
||||
items []repository.PeriodeTarifLayanan
|
||||
err error
|
||||
id int64
|
||||
params repository.PeriodeTarifLayananListParams
|
||||
total int
|
||||
}
|
||||
|
||||
func (f *fakePeriodeTarifDetailRepository) ListPeriodeTarifLayanan(_ context.Context, params repository.PeriodeTarifLayananListParams) (repository.PeriodeTarifLayananPage, error) {
|
||||
f.id = params.PeriodeTarifID
|
||||
f.params = params
|
||||
total := f.total
|
||||
if total == 0 {
|
||||
total = len(f.items)
|
||||
}
|
||||
return repository.PeriodeTarifLayananPage{Items: f.items, Total: total}, f.err
|
||||
}
|
||||
|
||||
func TestListPeriodeTarifLayananReturnsRows(t *testing.T) {
|
||||
layananID, nama, departemen, kelas, namaKelas, harga := "6413", "Darah Lengkap", "LAB", "Default", "Default", "125000.00"
|
||||
repo := &fakePeriodeTarifDetailRepository{items: []repository.PeriodeTarifLayanan{{
|
||||
TarifLayananID: 10, LayananID: &layananID, NamaLayanan: &nama,
|
||||
LayananDepartemenID: 20, Departemen: &departemen, Kelas: kelas, NamaKelas: &namaKelas,
|
||||
Harga: &harga,
|
||||
}}}
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/cpone/periode-tarif/12?search=Darah&page=2", nil)
|
||||
request.SetPathValue("periodeTarifID", "12")
|
||||
|
||||
Handler{periodeTarifDetailRepo: repo}.ListPeriodeTarifLayanan(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK || repo.id != 12 {
|
||||
t.Fatalf("status=%d id=%d body=%s", recorder.Code, repo.id, recorder.Body.String())
|
||||
}
|
||||
for _, expected := range []string{
|
||||
`"LayananID":"6413"`, `"NamaLayanan":"Darah Lengkap"`,
|
||||
`"LayananDepartemenID":20`, `"Departemen":"LAB"`,
|
||||
`"Kelas":"Default"`, `"Harga":"125000.00"`,
|
||||
} {
|
||||
if !strings.Contains(recorder.Body.String(), expected) {
|
||||
t.Fatalf("response missing %s: %s", expected, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListPeriodeTarifLayananUsesSearchAndPagination(t *testing.T) {
|
||||
repo := &fakePeriodeTarifDetailRepository{total: 41}
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "http://localhost/api/cpone/periode-tarif/12?search=Admin&page=1", nil)
|
||||
request.SetPathValue("periodeTarifID", "12")
|
||||
|
||||
Handler{periodeTarifDetailRepo: repo}.ListPeriodeTarifLayanan(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 != periodeTarifLayananPerPage || repo.params.Search != "Admin" {
|
||||
t.Fatalf("params=%+v", repo.params)
|
||||
}
|
||||
for _, expected := range []string{
|
||||
`"current_page":1`, `"last_page":3`, `"per_page":20`, `"total":41`,
|
||||
`"next_page_url":"http://localhost/api/cpone/periode-tarif/12?page=2\u0026per_page=20\u0026search=Admin"`,
|
||||
} {
|
||||
if !strings.Contains(recorder.Body.String(), expected) {
|
||||
t.Fatalf("response missing %s: %s", expected, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListPeriodeTarifLayananValidationAndMissingTable(t *testing.T) {
|
||||
for _, rawID := range []string{"", "abc", "0", "1.5"} {
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/cpone/periode-tarif/"+rawID, nil)
|
||||
request.SetPathValue("periodeTarifID", rawID)
|
||||
Handler{periodeTarifDetailRepo: &fakePeriodeTarifDetailRepository{}}.ListPeriodeTarifLayanan(recorder, request)
|
||||
if recorder.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("id=%q status=%d body=%s", rawID, recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/cpone/periode-tarif/12", nil)
|
||||
request.SetPathValue("periodeTarifID", "12")
|
||||
Handler{periodeTarifDetailRepo: &fakePeriodeTarifDetailRepository{err: repository.ErrTarifTableNotFound}}.
|
||||
ListPeriodeTarifLayanan(recorder, request)
|
||||
if recorder.Code != http.StatusNotFound || !strings.Contains(recorder.Body.String(), "harga_layanan_12") {
|
||||
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestListPeriodeTarifLayananInternalError(t *testing.T) {
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/cpone/periode-tarif/12", nil)
|
||||
request.SetPathValue("periodeTarifID", "12")
|
||||
Handler{periodeTarifDetailRepo: &fakePeriodeTarifDetailRepository{err: errors.New("query failed")}}.
|
||||
ListPeriodeTarifLayanan(recorder, request)
|
||||
if recorder.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
72
internal/http/periode_tarif_test.go
Normal file
72
internal/http/periode_tarif_test.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"primaya-api/cpone-middleware/internal/repository"
|
||||
)
|
||||
|
||||
type fakePeriodeTarifRepository struct {
|
||||
items []repository.PeriodeTarif
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakePeriodeTarifRepository) ListPeriodeTarif(_ context.Context) ([]repository.PeriodeTarif, error) {
|
||||
return f.items, f.err
|
||||
}
|
||||
|
||||
func (f *fakePeriodeTarifRepository) ListPeriodeTarifLayanan(_ context.Context, _ repository.PeriodeTarifLayananListParams) (repository.PeriodeTarifLayananPage, error) {
|
||||
return repository.PeriodeTarifLayananPage{}, f.err
|
||||
}
|
||||
|
||||
func TestListPeriodeTarifReturnsAvailablePeriods(t *testing.T) {
|
||||
keterangan := "Tarif Dasar 2026"
|
||||
repo := &fakePeriodeTarifRepository{items: []repository.PeriodeTarif{{
|
||||
PeriodeTarifID: 42, Keterangan: &keterangan,
|
||||
TanggalBerlaku: "2026-01-01", TanggalBerakhir: "2026-12-31",
|
||||
}}}
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/cpone/periode-tarif", nil)
|
||||
Handler{periodeTarifRepo: repo}.ListPeriodeTarif(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
for _, expected := range []string{
|
||||
`"success":true`, `"message":"ok"`, `"PeriodeTarifID":42`,
|
||||
`"Keterangan":"Tarif Dasar 2026"`, `"TanggalBerlaku":"2026-01-01"`,
|
||||
`"TanggalBerakhir":"2026-12-31"`,
|
||||
} {
|
||||
if !strings.Contains(recorder.Body.String(), expected) {
|
||||
t.Fatalf("response missing %s: %s", expected, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListPeriodeTarifReturnsEmptyArray(t *testing.T) {
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/cpone/periode-tarif", nil)
|
||||
Handler{periodeTarifRepo: &fakePeriodeTarifRepository{items: []repository.PeriodeTarif{}}}.
|
||||
ListPeriodeTarif(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), `"data":[]`) {
|
||||
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestListPeriodeTarifInternalError(t *testing.T) {
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/cpone/periode-tarif", nil)
|
||||
Handler{periodeTarifRepo: &fakePeriodeTarifRepository{err: errors.New("query failed")}}.
|
||||
ListPeriodeTarif(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.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())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
204
internal/http/registration.go
Normal file
204
internal/http/registration.go
Normal file
@@ -0,0 +1,204 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"primaya-api/cpone-middleware/internal/repository"
|
||||
)
|
||||
|
||||
func (h Handler) RegisterPatient(w http.ResponseWriter, r *http.Request) {
|
||||
payload, err := decodeJSONObject(r)
|
||||
if err != nil {
|
||||
writeRegistrationResponse(w, http.StatusUnprocessableEntity, false, "validation_error", "Payload harus berupa object JSON", nil)
|
||||
return
|
||||
}
|
||||
params, message := validateRegistration(payload, h.departmentID)
|
||||
if message != "" {
|
||||
writeRegistrationResponse(w, http.StatusUnprocessableEntity, false, "validation_error", message, nil)
|
||||
return
|
||||
}
|
||||
|
||||
outcome, err := h.registrationRepository(r.Context()).RegisterPatient(r.Context(), params)
|
||||
if err != nil {
|
||||
var validationErr repository.ValidationError
|
||||
if errors.As(err, &validationErr) {
|
||||
writeRegistrationResponse(w, http.StatusUnprocessableEntity, false, "validation_error", validationErr.Message, nil)
|
||||
return
|
||||
}
|
||||
writeRegistrationResponse(w, http.StatusInternalServerError, false, "error", err.Error(), nil)
|
||||
return
|
||||
}
|
||||
writeRegistrationResponse(w, outcome.HTTPStatus, outcome.Success, outcome.Status, outcome.Message, outcome.Data)
|
||||
}
|
||||
|
||||
func validateRegistration(payload map[string]any, configuredDepartment string) (repository.RegistrationParams, string) {
|
||||
medrecID, _, message := optionalString(payload, "medrec_id", 50)
|
||||
if message != "" {
|
||||
return repository.RegistrationParams{}, message
|
||||
}
|
||||
nik, _, message := optionalString(payload, "nik", 50)
|
||||
if message != "" {
|
||||
return repository.RegistrationParams{}, message
|
||||
}
|
||||
birthDateText, birthPresent, message := optionalString(payload, "tgl_lahir", 10)
|
||||
if message != "" {
|
||||
return repository.RegistrationParams{}, message
|
||||
}
|
||||
if strings.TrimSpace(medrecID) == "" && (!birthPresent || strings.TrimSpace(nik) == "") {
|
||||
return repository.RegistrationParams{}, "nik dan tgl_lahir wajib diisi jika medrec_id belum tersedia"
|
||||
}
|
||||
|
||||
jakarta, err := time.LoadLocation("Asia/Jakarta")
|
||||
if err != nil {
|
||||
return repository.RegistrationParams{}, err.Error()
|
||||
}
|
||||
var birthDate time.Time
|
||||
if birthPresent && strings.TrimSpace(birthDateText) != "" {
|
||||
birthDate, err = time.ParseInLocation("2006-01-02", birthDateText, jakarta)
|
||||
if err != nil {
|
||||
return repository.RegistrationParams{}, "tgl_lahir harus format Y-m-d"
|
||||
}
|
||||
}
|
||||
|
||||
registrationDateText, present, message := optionalString(payload, "tanggal", 10)
|
||||
if message != "" {
|
||||
return repository.RegistrationParams{}, message
|
||||
}
|
||||
registrationDate := time.Now().In(jakarta)
|
||||
if present && strings.TrimSpace(registrationDateText) != "" {
|
||||
registrationDate, err = time.ParseInLocation("2006-01-02", registrationDateText, jakarta)
|
||||
if err != nil {
|
||||
return repository.RegistrationParams{}, "tanggal harus format Y-m-d"
|
||||
}
|
||||
}
|
||||
|
||||
departmentID, present, message := optionalString(payload, "departemen_id", 50)
|
||||
if message != "" {
|
||||
return repository.RegistrationParams{}, message
|
||||
}
|
||||
if !present || strings.TrimSpace(departmentID) == "" {
|
||||
departmentID = configuredDepartment
|
||||
}
|
||||
if strings.TrimSpace(departmentID) == "" {
|
||||
return repository.RegistrationParams{}, "departemen_id wajib diisi atau dikonfigurasi di middleware"
|
||||
}
|
||||
|
||||
doctorID, _, message := optionalString(payload, "dokter_id", 50)
|
||||
if message != "" {
|
||||
return repository.RegistrationParams{}, message
|
||||
}
|
||||
patientType, message := optionalInteger(payload, "jenis_pasien_id", 0)
|
||||
if message != "" {
|
||||
return repository.RegistrationParams{}, message
|
||||
}
|
||||
registrationType, message := optionalInteger(payload, "jenis_reg_id", 2)
|
||||
if message != "" {
|
||||
return repository.RegistrationParams{}, message
|
||||
}
|
||||
tariffPeriod, message := optionalInteger(payload, "periode_tarif_id", 0)
|
||||
if message != "" {
|
||||
return repository.RegistrationParams{}, message
|
||||
}
|
||||
groupTariff, message := optionalInteger(payload, "group_tarif_id", 0)
|
||||
if message != "" {
|
||||
return repository.RegistrationParams{}, message
|
||||
}
|
||||
|
||||
companyID, _, message := optionalString(payload, "perusahaan_id", 50)
|
||||
if message != "" {
|
||||
return repository.RegistrationParams{}, message
|
||||
}
|
||||
insuranceID, _, message := optionalString(payload, "asuransi_id", 50)
|
||||
if message != "" {
|
||||
return repository.RegistrationParams{}, message
|
||||
}
|
||||
providerID, _, message := optionalString(payload, "provider_id", 50)
|
||||
if message != "" {
|
||||
return repository.RegistrationParams{}, message
|
||||
}
|
||||
policyNumber, _, message := optionalString(payload, "nomer_polis", 100)
|
||||
if message != "" {
|
||||
return repository.RegistrationParams{}, message
|
||||
}
|
||||
guaranteeNumber, _, message := optionalString(payload, "nomer_jaminan", 100)
|
||||
if message != "" {
|
||||
return repository.RegistrationParams{}, message
|
||||
}
|
||||
entryCode, _, message := optionalString(payload, "kode_masuk_id", 50)
|
||||
if message != "" {
|
||||
return repository.RegistrationParams{}, message
|
||||
}
|
||||
classID, present, message := optionalString(payload, "kelas_id", 50)
|
||||
if message != "" {
|
||||
return repository.RegistrationParams{}, message
|
||||
}
|
||||
if !present || strings.TrimSpace(classID) == "" {
|
||||
classID = "Default"
|
||||
}
|
||||
notes, _, message := optionalString(payload, "catatan", 500)
|
||||
if message != "" {
|
||||
return repository.RegistrationParams{}, message
|
||||
}
|
||||
channel, present, message := optionalString(payload, "channel", 50)
|
||||
if message != "" {
|
||||
return repository.RegistrationParams{}, message
|
||||
}
|
||||
if !present || strings.TrimSpace(channel) == "" {
|
||||
channel = "CPONE"
|
||||
}
|
||||
referrer, _, message := optionalString(payload, "perujuk", 150)
|
||||
if message != "" {
|
||||
return repository.RegistrationParams{}, message
|
||||
}
|
||||
employeeID, _, message := optionalString(payload, "nik_karyawan", 100)
|
||||
if message != "" {
|
||||
return repository.RegistrationParams{}, message
|
||||
}
|
||||
employeeName, _, message := optionalString(payload, "nama_karyawan", 150)
|
||||
if message != "" {
|
||||
return repository.RegistrationParams{}, message
|
||||
}
|
||||
packageID, packagePresent, message := optionalString(payload, "kode_paket", 50)
|
||||
if message != "" {
|
||||
return repository.RegistrationParams{}, message
|
||||
}
|
||||
if !packagePresent || strings.TrimSpace(packageID) == "" {
|
||||
packageID, _, message = optionalString(payload, "paket_id", 50)
|
||||
if message != "" {
|
||||
return repository.RegistrationParams{}, message
|
||||
}
|
||||
}
|
||||
packageID = strings.ToUpper(strings.TrimSpace(packageID))
|
||||
if packageID != "" && !labIdentifier.MatchString(packageID) {
|
||||
return repository.RegistrationParams{}, "kode_paket hanya boleh berisi huruf, angka, dan tanda hubung"
|
||||
}
|
||||
|
||||
return repository.RegistrationParams{
|
||||
MedrecID: strings.TrimSpace(medrecID), NIK: normalizeIdentity(nik), BirthDate: birthDate,
|
||||
RegistrationDate: registrationDate, DepartmentID: strings.TrimSpace(departmentID),
|
||||
DoctorID: strings.TrimSpace(doctorID), PatientTypeID: patientType, RegistrationType: registrationType,
|
||||
TariffPeriodID: tariffPeriod, CompanyID: strings.TrimSpace(companyID), InsuranceID: strings.TrimSpace(insuranceID),
|
||||
GroupTariffID: groupTariff, ProviderID: strings.TrimSpace(providerID), PolicyNumber: strings.TrimSpace(policyNumber),
|
||||
GuaranteeNumber: strings.TrimSpace(guaranteeNumber), EntryCode: strings.TrimSpace(entryCode), ClassID: strings.TrimSpace(classID),
|
||||
Notes: strings.TrimSpace(notes), Referrer: strings.TrimSpace(referrer), EmployeeID: strings.TrimSpace(employeeID), EmployeeName: strings.TrimSpace(employeeName), Channel: normalizeText(channel), PackageID: packageID,
|
||||
}, ""
|
||||
}
|
||||
|
||||
func optionalInteger(payload map[string]any, key string, fallback int64) (int64, string) {
|
||||
value, present := payload[key]
|
||||
if !present || isLaravelEmpty(value) {
|
||||
return fallback, ""
|
||||
}
|
||||
number, ok := parseInteger(value)
|
||||
if !ok || number < 0 {
|
||||
return 0, key + " harus berupa angka"
|
||||
}
|
||||
return number, ""
|
||||
}
|
||||
|
||||
func writeRegistrationResponse(w http.ResponseWriter, statusCode int, success bool, status, message string, data any) {
|
||||
WriteJSON(w, statusCode, MedrecResponse{Success: success, Status: status, Message: message, Data: data})
|
||||
}
|
||||
84
internal/http/registration_test.go
Normal file
84
internal/http/registration_test.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"primaya-api/cpone-middleware/internal/repository"
|
||||
)
|
||||
|
||||
type fakeRegistrationRepository struct {
|
||||
outcome repository.RegistrationOutcome
|
||||
err error
|
||||
params repository.RegistrationParams
|
||||
}
|
||||
|
||||
func (f *fakeRegistrationRepository) RegisterPatient(_ context.Context, params repository.RegistrationParams) (repository.RegistrationOutcome, error) {
|
||||
f.params = params
|
||||
return f.outcome, f.err
|
||||
}
|
||||
|
||||
func TestRegisterPatientUsesConfiguredDepartmentAndNormalizesPayload(t *testing.T) {
|
||||
repo := &fakeRegistrationRepository{outcome: repository.RegistrationOutcome{
|
||||
HTTPStatus: http.StatusCreated,
|
||||
Success: true,
|
||||
Status: "registered",
|
||||
Message: "Registrasi berhasil disimpan",
|
||||
}}
|
||||
handler := Handler{registrationRepo: repo, departmentID: "08_01"}
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/cpone/patients/registration", strings.NewReader(`{
|
||||
"nik":"3174-0115-0190-0001",
|
||||
"tgl_lahir":"1990-01-15",
|
||||
"tanggal":"2026-09-07",
|
||||
"dokter_id":"driqbal",
|
||||
"jenis_pasien_id":"1",
|
||||
"jenis_reg_id":2,
|
||||
"periode_tarif_id":"10",
|
||||
"perusahaan_id":"ADMEDIKAAA",
|
||||
"group_tarif_id":18,
|
||||
"kode_paket":"PN2600036",
|
||||
"kelas_id":"Default",
|
||||
"channel":"cpone"
|
||||
}`))
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.RegisterPatient(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusCreated {
|
||||
t.Fatalf("status = %d; body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if repo.params.DepartmentID != "08_01" || repo.params.NIK != "3174011501900001" || repo.params.RegistrationType != 2 {
|
||||
t.Fatalf("unexpected normalized params: %+v", repo.params)
|
||||
}
|
||||
if repo.params.PatientTypeID != 1 || repo.params.TariffPeriodID != 10 || repo.params.GroupTariffID != 18 || repo.params.Channel != "CPONE" {
|
||||
t.Fatalf("unexpected numeric/text params: %+v", repo.params)
|
||||
}
|
||||
if repo.params.PackageID != "PN2600036" {
|
||||
t.Fatalf("unexpected package: %+v", repo.params)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterPatientRequiresIdentityWhenMedrecIsMissing(t *testing.T) {
|
||||
handler := Handler{registrationRepo: &fakeRegistrationRepository{}, departmentID: "08_01"}
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/cpone/patients/registration", strings.NewReader(`{"tanggal":"2026-09-07"}`))
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.RegisterPatient(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusUnprocessableEntity || !strings.Contains(recorder.Body.String(), "nik dan tgl_lahir wajib") {
|
||||
t.Fatalf("unexpected validation response: status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterPatientForwardsRepositoryError(t *testing.T) {
|
||||
handler := Handler{registrationRepo: &fakeRegistrationRepository{err: errors.New("database unavailable")}, departmentID: "08_01"}
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/cpone/patients/registration", strings.NewReader(`{"medrec_id":"00001234"}`))
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.RegisterPatient(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusInternalServerError || !strings.Contains(recorder.Body.String(), "database unavailable") {
|
||||
t.Fatalf("unexpected error response: status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,7 @@ func (h Handler) ListTarifKerjasama(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
page, err := h.tarifRepo.ListTarifKerjasama(r.Context(), params)
|
||||
page, err := h.tarifRepository(r.Context()).ListTarifKerjasama(r.Context(), params)
|
||||
if err != nil {
|
||||
writeTarifKerjasamaInternalError(w)
|
||||
return
|
||||
@@ -72,7 +72,7 @@ func (h Handler) GetTarifKerjasama(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
detail, err := h.tarifRepo.GetTarifKerjasama(r.Context(), id, params)
|
||||
detail, err := h.tarifRepository(r.Context()).GetTarifKerjasama(r.Context(), id, params)
|
||||
if err != nil {
|
||||
writeTarifKerjasamaInternalError(w)
|
||||
return
|
||||
|
||||
137
internal/repository/dokter.go
Normal file
137
internal/repository/dokter.go
Normal file
@@ -0,0 +1,137 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type DokterListParams struct {
|
||||
Search string
|
||||
DoctorIDs []string
|
||||
Page int
|
||||
PerPage int
|
||||
}
|
||||
|
||||
type DokterPage struct {
|
||||
Items []Dokter
|
||||
Total int
|
||||
}
|
||||
|
||||
type Dokter struct {
|
||||
KodeRS *string `json:"KodeRs"`
|
||||
NamaRS *string `json:"NamaRs"`
|
||||
DokterID string `json:"DokterID"`
|
||||
Nama *string `json:"Nama"`
|
||||
Sapaan *string `json:"Sapaan"`
|
||||
Inisial *string `json:"Inisial"`
|
||||
Gelar *string `json:"Gelar"`
|
||||
GelarBelakang *string `json:"Gelar_belakang"`
|
||||
JenisKaryawanID *string `json:"JenisKaryawanID"`
|
||||
Kelamin *string `json:"Kelamin"`
|
||||
GolonganDarah *string `json:"GolonganDarah"`
|
||||
TempatLahir *string `json:"TempatLahir"`
|
||||
TanggalLahir *string `json:"TanggalLahir"`
|
||||
NIK *string `json:"NIK"`
|
||||
Telepon *string `json:"Telepon"`
|
||||
Ponsel *string `json:"Ponsel"`
|
||||
Email *string `json:"Email"`
|
||||
Alamat *string `json:"Alamat"`
|
||||
Kota *string `json:"Kota"`
|
||||
KodePos *string `json:"KodePos"`
|
||||
Propinsi *string `json:"Propinsi"`
|
||||
Negara *string `json:"Negara"`
|
||||
TanggalMulai *string `json:"TanggalMulai"`
|
||||
Keilmuan *string `json:"Keilmuan"`
|
||||
SpesialisasiID *string `json:"SpesialisasiID"`
|
||||
KodeSpesialisasiID *string `json:"KodeSpesialisasiID"`
|
||||
KodeSubSpesialisasiID *string `json:"KodeSubSpesialisasiID"`
|
||||
Tipe *string `json:"Tipe"`
|
||||
NA *string `json:"NA"`
|
||||
}
|
||||
|
||||
func (r MySQLLayananRepository) ListDokter(ctx context.Context, params DokterListParams) (DokterPage, error) {
|
||||
where, args := dokterListWhere(params)
|
||||
|
||||
var total int
|
||||
if err := r.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM dokter d "+where, args...).Scan(&total); err != nil {
|
||||
return DokterPage{}, err
|
||||
}
|
||||
|
||||
queryArgs := append(append([]any{}, args...), params.PerPage, (params.Page-1)*params.PerPage)
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT
|
||||
d.DokterID, d.Nama, d.Sapaan, d.Inisial, d.Gelar, d.Gelar_belakang,
|
||||
d.JenisKaryawanID, d.Kelamin, d.GolonganDarah, d.TempatLahir,
|
||||
d.TanggalLahir, d.NIK, d.Telepon, d.Ponsel, d.Email, d.Alamat,
|
||||
d.Kota, d.KodePos, d.Propinsi, d.Negara, d.TanggalMulai, d.Keilmuan,
|
||||
d.SpesialisasiID, d.KodeSpesialisasiID, d.KodeSubSpesialisasiID,
|
||||
d.Tipe, d.NA,
|
||||
(SELECT rs.KodeRS FROM rs ORDER BY rs.RSID LIMIT 1),
|
||||
(SELECT rs.Nama FROM rs ORDER BY rs.RSID LIMIT 1)
|
||||
FROM dokter d
|
||||
`+where+`
|
||||
ORDER BY d.Nama, d.DokterID
|
||||
LIMIT ? OFFSET ?`, queryArgs...)
|
||||
if err != nil {
|
||||
return DokterPage{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := make([]Dokter, 0)
|
||||
for rows.Next() {
|
||||
item, err := scanDokter(rows)
|
||||
if err != nil {
|
||||
return DokterPage{}, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return DokterPage{}, err
|
||||
}
|
||||
|
||||
return DokterPage{Items: items, Total: total}, nil
|
||||
}
|
||||
|
||||
func dokterListWhere(params DokterListParams) (string, []any) {
|
||||
conditions := []string{"d.NA = 'N'"}
|
||||
args := make([]any, 0, 2+len(params.DoctorIDs))
|
||||
|
||||
if params.Search != "" {
|
||||
conditions = append(conditions, "(d.DokterID LIKE ? OR d.Nama LIKE ?)")
|
||||
like := "%" + params.Search + "%"
|
||||
args = append(args, like, like)
|
||||
}
|
||||
if len(params.DoctorIDs) > 0 {
|
||||
placeholders := make([]string, len(params.DoctorIDs))
|
||||
for i, id := range params.DoctorIDs {
|
||||
placeholders[i] = "?"
|
||||
args = append(args, id)
|
||||
}
|
||||
conditions = append(conditions, "d.DokterID IN ("+strings.Join(placeholders, ",")+")")
|
||||
}
|
||||
|
||||
return "WHERE " + strings.Join(conditions, " AND "), args
|
||||
}
|
||||
|
||||
func scanDokter(rows *sql.Rows) (Dokter, error) {
|
||||
var item Dokter
|
||||
var kodeRS, namaRS sql.NullString
|
||||
|
||||
err := rows.Scan(
|
||||
&item.DokterID, &item.Nama, &item.Sapaan, &item.Inisial, &item.Gelar, &item.GelarBelakang,
|
||||
&item.JenisKaryawanID, &item.Kelamin, &item.GolonganDarah, &item.TempatLahir,
|
||||
&item.TanggalLahir, &item.NIK, &item.Telepon, &item.Ponsel, &item.Email, &item.Alamat,
|
||||
&item.Kota, &item.KodePos, &item.Propinsi, &item.Negara, &item.TanggalMulai, &item.Keilmuan,
|
||||
&item.SpesialisasiID, &item.KodeSpesialisasiID, &item.KodeSubSpesialisasiID,
|
||||
&item.Tipe, &item.NA, &kodeRS, &namaRS,
|
||||
)
|
||||
if err != nil {
|
||||
return Dokter{}, fmt.Errorf("scan dokter: %w", err)
|
||||
}
|
||||
|
||||
item.KodeRS = nullStringPtr(kodeRS)
|
||||
item.NamaRS = nullStringPtr(namaRS)
|
||||
return item, nil
|
||||
}
|
||||
374
internal/repository/lab_package.go
Normal file
374
internal/repository/lab_package.go
Normal file
@@ -0,0 +1,374 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type AddLabPackageParams struct {
|
||||
PackageID string
|
||||
Cito bool
|
||||
MarkupCito float64
|
||||
Notes string
|
||||
Channel string
|
||||
}
|
||||
|
||||
type labPackageHeader struct {
|
||||
ID string
|
||||
Name string
|
||||
Days sql.NullInt64
|
||||
TotalPrice sql.NullString
|
||||
}
|
||||
|
||||
type labPackageService struct {
|
||||
ID int64
|
||||
DepartmentID string
|
||||
ServiceID string
|
||||
HeaderService sql.NullString
|
||||
Quantity sql.NullString
|
||||
Days sql.NullInt64
|
||||
Price sql.NullString
|
||||
DiscountType sql.NullString
|
||||
Discount sql.NullString
|
||||
ShareType sql.NullString
|
||||
ShareHospital sql.NullString
|
||||
ShareDoctor sql.NullString
|
||||
ShareExternal sql.NullString
|
||||
ShareOther sql.NullString
|
||||
}
|
||||
|
||||
func (r MySQLLayananRepository) AddLabPackage(ctx context.Context, regID string, params AddLabPackageParams) (LabRegistrationOutcome, error) {
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < 3; attempt++ {
|
||||
outcome, err := r.addLabPackageAttempt(ctx, regID, params)
|
||||
if err == nil {
|
||||
return outcome, nil
|
||||
}
|
||||
lastErr = err
|
||||
if !isConcurrencyError(err) || ctx.Err() != nil {
|
||||
return LabRegistrationOutcome{}, err
|
||||
}
|
||||
}
|
||||
return LabRegistrationOutcome{}, lastErr
|
||||
}
|
||||
|
||||
func (r MySQLLayananRepository) addLabPackageAttempt(ctx context.Context, regID string, params AddLabPackageParams) (outcome LabRegistrationOutcome, err error) {
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return LabRegistrationOutcome{}, err
|
||||
}
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
_ = tx.Rollback()
|
||||
panic(recovered)
|
||||
}
|
||||
if err != nil {
|
||||
_ = tx.Rollback()
|
||||
}
|
||||
}()
|
||||
outcome, err = addLabPackageInTransaction(ctx, tx, strings.ToUpper(strings.TrimSpace(regID)), params)
|
||||
if err != nil {
|
||||
return LabRegistrationOutcome{}, err
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return LabRegistrationOutcome{}, err
|
||||
}
|
||||
return outcome, nil
|
||||
}
|
||||
|
||||
func addLabPackageInTransaction(ctx context.Context, tx *sql.Tx, regID string, params AddLabPackageParams) (LabRegistrationOutcome, error) {
|
||||
registration, found, err := findLabRegistrationStateForUpdate(ctx, tx, regID)
|
||||
if err != nil {
|
||||
return LabRegistrationOutcome{}, err
|
||||
}
|
||||
if !found {
|
||||
return labOutcome(404, false, "registration_not_found", "Registrasi laboratorium tidak ditemukan", nil), nil
|
||||
}
|
||||
if registration.BatalDaftar == "Y" {
|
||||
return labOutcome(409, false, "registration_cancelled", "Registrasi laboratorium sudah dibatalkan", nil), nil
|
||||
}
|
||||
if registration.Pulang == "Y" {
|
||||
return labOutcome(409, false, "registration_closed", "Registrasi laboratorium sudah ditutup", nil), nil
|
||||
}
|
||||
transaction, found, err := findLabTransactionStateForUpdate(ctx, tx, registration.Registration.RegID, registration.Registration.DepartemenID)
|
||||
if err != nil {
|
||||
return LabRegistrationOutcome{}, err
|
||||
}
|
||||
if !found {
|
||||
return labOutcome(404, false, "transaction_not_found", "Transaksi departemen laboratorium tidak ditemukan", nil), nil
|
||||
}
|
||||
|
||||
packageHeader, found, err := findActiveOutpatientPackage(ctx, tx, params.PackageID, registration.Registration.Tanggal)
|
||||
if err != nil {
|
||||
return LabRegistrationOutcome{}, err
|
||||
}
|
||||
if !found {
|
||||
return labOutcome(404, false, "package_not_found", "Paket dispenser rawat jalan tidak ditemukan, belum disetujui, atau sudah tidak berlaku", nil), nil
|
||||
}
|
||||
packageServices, err := findActivePackageServices(ctx, tx, packageHeader.ID)
|
||||
if err != nil {
|
||||
return LabRegistrationOutcome{}, err
|
||||
}
|
||||
if len(packageServices) == 0 {
|
||||
return LabRegistrationOutcome{}, ValidationError{Message: "Paket dispenser tidak memiliki layanan aktif"}
|
||||
}
|
||||
|
||||
alreadyAdded, err := packageAlreadyAdded(ctx, tx, transaction.Transaction.TrxDepartemenID, packageHeader.ID)
|
||||
if err != nil {
|
||||
return LabRegistrationOutcome{}, err
|
||||
}
|
||||
if alreadyAdded {
|
||||
data, err := buildLabRegistrationData(ctx, tx, registration.Registration, &transaction.Transaction)
|
||||
if err != nil {
|
||||
return LabRegistrationOutcome{}, err
|
||||
}
|
||||
data.Package = &LabPackage{PaketID: packageHeader.ID, Nama: packageHeader.Name}
|
||||
return labOutcome(200, true, "package_already_added", "Paket dispenser sudah terdaftar pada kunjungan laboratorium ini", &data), nil
|
||||
}
|
||||
serviceIDs := make([]string, len(packageServices))
|
||||
for i, service := range packageServices {
|
||||
serviceIDs[i] = service.ServiceID
|
||||
}
|
||||
conflicts, err := findExistingLabServiceIDs(ctx, tx, transaction.Transaction.TrxDepartemenID, serviceIDs)
|
||||
if err != nil {
|
||||
return LabRegistrationOutcome{}, err
|
||||
}
|
||||
if len(conflicts) > 0 {
|
||||
data, err := buildLabRegistrationData(ctx, tx, registration.Registration, &transaction.Transaction)
|
||||
if err != nil {
|
||||
return LabRegistrationOutcome{}, err
|
||||
}
|
||||
data.SkippedLayananIDs = &conflicts
|
||||
return labOutcome(409, false, "package_service_conflict", "Paket memiliki layanan yang sudah ada pada registrasi laboratorium", &data), nil
|
||||
}
|
||||
if registration.Registration.GroupTarifID.Valid {
|
||||
active, err := exists(ctx, tx, `SELECT EXISTS(SELECT 1 FROM perusahaan_grouptarif WHERE GroupTarifID = ? AND NA = 'N')`, registration.Registration.GroupTarifID.Int64)
|
||||
if err != nil {
|
||||
return LabRegistrationOutcome{}, err
|
||||
}
|
||||
if !active {
|
||||
return LabRegistrationOutcome{}, ValidationError{Message: "Group tarif registrasi tidak ditemukan atau sudah tidak aktif"}
|
||||
}
|
||||
}
|
||||
|
||||
jkt, err := time.LoadLocation("Asia/Jakarta")
|
||||
if err != nil {
|
||||
return LabRegistrationOutcome{}, err
|
||||
}
|
||||
now := time.Now().In(jkt).Format("2006-01-02 15:04:05")
|
||||
dispenserID, visitNumber, err := ensurePackageDispenser(ctx, tx, registration.Registration, packageHeader, params.Channel, now)
|
||||
if err != nil {
|
||||
return LabRegistrationOutcome{}, err
|
||||
}
|
||||
classID := "Default"
|
||||
if transaction.KelasID.Valid && strings.TrimSpace(transaction.KelasID.String) != "" {
|
||||
classID = transaction.KelasID.String
|
||||
} else if registration.KelasID.Valid && strings.TrimSpace(registration.KelasID.String) != "" {
|
||||
classID = registration.KelasID.String
|
||||
}
|
||||
doctorID := ""
|
||||
if transaction.DokterID.Valid {
|
||||
doctorID = transaction.DokterID.String
|
||||
} else if registration.Registration.DokterID.Valid {
|
||||
doctorID = registration.Registration.DokterID.String
|
||||
}
|
||||
cito := "N"
|
||||
if params.Cito {
|
||||
cito = "Y"
|
||||
}
|
||||
addedIDs := make([]string, 0, len(packageServices))
|
||||
for _, packageService := range packageServices {
|
||||
master, found, err := findLabServiceMaster(ctx, tx, packageService.ServiceID, packageService.DepartmentID)
|
||||
if err != nil {
|
||||
return LabRegistrationOutcome{}, err
|
||||
}
|
||||
if !found {
|
||||
return LabRegistrationOutcome{}, ValidationError{Message: fmt.Sprintf("Layanan %s pada paket tidak aktif di departemen %s", packageService.ServiceID, packageService.DepartmentID)}
|
||||
}
|
||||
operationID, err := resolvePackageOperationID(ctx, tx, packageService.HeaderService)
|
||||
if err != nil {
|
||||
return LabRegistrationOutcome{}, err
|
||||
}
|
||||
dispenserServiceID, err := insertPackageDispenserService(ctx, tx, dispenserID, registration.Registration, packageHeader.ID, packageService, operationID, params.Channel, now)
|
||||
if err != nil {
|
||||
return LabRegistrationOutcome{}, err
|
||||
}
|
||||
price := math.Max(0, parseNullableDecimal(packageService.Price))
|
||||
discount := math.Max(0, parseNullableDecimal(packageService.Discount))
|
||||
discountType := "Percentage"
|
||||
if packageService.DiscountType.Valid && packageService.DiscountType.String != "" {
|
||||
discountType = packageService.DiscountType.String
|
||||
}
|
||||
net := applyPackageDiscount(price, discount, discountType)
|
||||
patient, guarantor := net, 0.0
|
||||
if registration.Registration.GroupTarifID.Valid && registration.Registration.GroupTarifID.Int64 > 0 {
|
||||
patient, guarantor = 0, net
|
||||
}
|
||||
resolved := resolvedLabService{
|
||||
LayananID: master.LayananID, LayananDepartemenID: master.LayananDepartemenID,
|
||||
Satuan: master.Satuan, UnitCost: nullDecimal(master.UnitCost), FlagTipe: master.FlagTipe,
|
||||
Jumlah: 1, Cito: cito, MarkupCito: params.MarkupCito, Harga: price,
|
||||
HargaDefault: price, Total: net, ShareTipe: packageString(packageService.ShareType, "Percentage"),
|
||||
ShareRS: parseNullableDecimal(packageService.ShareHospital), ShareDokter: parseNullableDecimal(packageService.ShareDoctor),
|
||||
ShareExternal: parseNullableDecimal(packageService.ShareExternal), ShareLain: parseNullableDecimal(packageService.ShareOther),
|
||||
Catatan: params.Notes,
|
||||
}
|
||||
trxServiceID, err := insertPackageTransactionService(ctx, tx, resolved, discountType, discount, patient, patient, guarantor, guarantor, packageHeader.ID, registration.Registration.RegID, transaction.Transaction.TrxDepartemenID, registration.Registration.MEDRECID, packageService.DepartmentID, doctorID, classID, params.Channel, now)
|
||||
if err != nil {
|
||||
return LabRegistrationOutcome{}, err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO trxlayanan_detail (TrxLayananID, PaketDispenserID, PaketDispenserLayananID, LoginBuat, TanggalBuat, NA) VALUES (?, ?, ?, ?, ?, 'N')`, trxServiceID, dispenserID, dispenserServiceID, params.Channel, now); err != nil {
|
||||
return LabRegistrationOutcome{}, err
|
||||
}
|
||||
addedIDs = append(addedIDs, master.LayananID)
|
||||
}
|
||||
if err := insertPackageAdministration(ctx, tx, dispenserID, registration.Registration.RegID, packageHeader.ID, params.Channel, now); err != nil {
|
||||
return LabRegistrationOutcome{}, err
|
||||
}
|
||||
if err := syncLabSpecimens(ctx, tx, transaction.Transaction.TrxDepartemenID, transaction.Transaction.TrxID, params.Channel, now); err != nil {
|
||||
return LabRegistrationOutcome{}, err
|
||||
}
|
||||
if err := updateLabTransactionTotal(ctx, tx, transaction.Transaction.TrxDepartemenID, registration.Registration.RegID, registration.Registration.DepartemenID); err != nil {
|
||||
return LabRegistrationOutcome{}, err
|
||||
}
|
||||
data, err := buildLabRegistrationData(ctx, tx, registration.Registration, &transaction.Transaction)
|
||||
if err != nil {
|
||||
return LabRegistrationOutcome{}, err
|
||||
}
|
||||
data.Package = &LabPackage{PaketDispenserID: dispenserID, PaketID: packageHeader.ID, Nama: packageHeader.Name, KunjunganKe: visitNumber, AddedLayananIDs: addedIDs}
|
||||
return labOutcome(201, true, "package_added", "Paket dispenser berhasil ditambahkan ke registrasi laboratorium", &data), nil
|
||||
}
|
||||
|
||||
func packageAlreadyAdded(ctx context.Context, tx *sql.Tx, trxDepartmentID int64, packageID string) (bool, error) {
|
||||
var marker int
|
||||
err := tx.QueryRowContext(ctx, `SELECT 1 FROM trxlayanan WHERE TrxDepartemenID = ? AND PaketID = ? AND NA = 'N' LIMIT 1 FOR UPDATE`, trxDepartmentID, packageID).Scan(&marker)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return false, nil
|
||||
}
|
||||
return err == nil, err
|
||||
}
|
||||
|
||||
func findActiveOutpatientPackage(ctx context.Context, tx *sql.Tx, packageID string, date time.Time) (labPackageHeader, bool, error) {
|
||||
var result labPackageHeader
|
||||
err := tx.QueryRowContext(ctx, `SELECT PaketID, Nama, JumlahHari, TotalHarga FROM paket_header WHERE PaketID = ? AND StatusID = 2 AND RJ = 1 AND NA = 'N' AND (TglDari IS NULL OR DATE(TglDari) <= DATE(?)) AND (TglSampai IS NULL OR DATE(TglSampai) >= DATE(?)) LIMIT 1 FOR UPDATE`, packageID, date, date).Scan(&result.ID, &result.Name, &result.Days, &result.TotalPrice)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return labPackageHeader{}, false, nil
|
||||
}
|
||||
return result, err == nil, err
|
||||
}
|
||||
|
||||
func findActivePackageServices(ctx context.Context, tx *sql.Tx, packageID string) ([]labPackageService, error) {
|
||||
rows, err := tx.QueryContext(ctx, `SELECT PaketLayananID, DepartemenID, LayananID, HeaderLayanan, Jumlah, JumlahHari, Harga, TipeDiskon, Diskon, ShareTipe, ShareRS, ShareDokter, ShareExternal, ShareLain FROM paket_layanan WHERE PaketID = ? AND NA = 'N' ORDER BY PaketLayananID`, packageID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := make([]labPackageService, 0)
|
||||
for rows.Next() {
|
||||
var item labPackageService
|
||||
if err := rows.Scan(&item.ID, &item.DepartmentID, &item.ServiceID, &item.HeaderService, &item.Quantity, &item.Days, &item.Price, &item.DiscountType, &item.Discount, &item.ShareType, &item.ShareHospital, &item.ShareDoctor, &item.ShareExternal, &item.ShareOther); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, item)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func ensurePackageDispenser(ctx context.Context, tx *sql.Tx, registration dbLabRegistration, header labPackageHeader, channel, now string) (int64, int64, error) {
|
||||
var dispenserID int64
|
||||
err := tx.QueryRowContext(ctx, `SELECT h.PaketDispenserID FROM paket_dispenser_header h WHERE h.PaketID = ? AND h.RegID = ? AND h.MEDRECID = ? AND h.NA = 'N' AND NOT EXISTS (SELECT 1 FROM paket_dispenser_layanan l WHERE l.PaketDispenserID = h.PaketDispenserID) AND NOT EXISTS (SELECT 1 FROM trxlayanan_detail d WHERE d.PaketDispenserID = h.PaketDispenserID) ORDER BY h.PaketDispenserID DESC LIMIT 1 FOR UPDATE`, header.ID, registration.RegID, registration.MEDRECID).Scan(&dispenserID)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, 0, err
|
||||
}
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
result, err := tx.ExecContext(ctx, `INSERT INTO paket_dispenser_header (PaketID, RegID, MEDRECID, JumlahHari, TotalHarga, LoginBuat, TanggalBuat, NA) VALUES (?, ?, ?, ?, ?, ?, ?, 'N')`, header.ID, registration.RegID, registration.MEDRECID, nullInt64Value(header.Days), nullStringValue(header.TotalPrice), channel, now)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
dispenserID, err = result.LastInsertId()
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
}
|
||||
var visit int64
|
||||
err = tx.QueryRowContext(ctx, `SELECT KunjunganKe FROM paket_dispenser_episode_rj WHERE PaketDispenserID = ? AND NA = 'N' LIMIT 1 FOR UPDATE`, dispenserID).Scan(&visit)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) + 1 FROM paket_dispenser_episode_rj e INNER JOIN paket_dispenser_header h ON h.PaketDispenserID = e.PaketDispenserID WHERE e.MEDRECID = ? AND h.PaketID = ? AND e.NA = 'N' AND h.NA = 'N'`, registration.MEDRECID, header.ID).Scan(&visit); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO paket_dispenser_episode_rj (PaketDispenserID, RegID, MEDRECID, KunjunganKe, LoginBuat, TanggalBuat, NA) VALUES (?, ?, ?, ?, ?, ?, 'N')`, dispenserID, registration.RegID, registration.MEDRECID, visit, channel, now)
|
||||
}
|
||||
return dispenserID, visit, err
|
||||
}
|
||||
|
||||
func resolvePackageOperationID(ctx context.Context, tx *sql.Tx, header sql.NullString) (int64, error) {
|
||||
if !header.Valid || strings.TrimSpace(header.String) == "" {
|
||||
return 0, nil
|
||||
}
|
||||
var id sql.NullInt64
|
||||
err := tx.QueryRowContext(ctx, `SELECT PaketDispenserOperasiID FROM paket_dispenser_operasi_header WHERE PaketOperasiHeaderID = ? LIMIT 1`, header.String).Scan(&id)
|
||||
if errors.Is(err, sql.ErrNoRows) || !id.Valid || id.Int64 < 1 {
|
||||
return 0, ValidationError{Message: "Mapping operasi untuk HeaderLayanan " + header.String + " tidak ditemukan"}
|
||||
}
|
||||
return id.Int64, err
|
||||
}
|
||||
|
||||
func insertPackageDispenserService(ctx context.Context, tx *sql.Tx, dispenserID int64, registration dbLabRegistration, packageID string, service labPackageService, operationID int64, channel, now string) (int64, error) {
|
||||
result, err := tx.ExecContext(ctx, `INSERT INTO paket_dispenser_layanan (PaketDispenserID, PaketDispenserOperasiID, RegID, PaketID, DepartemenID, LayananID, HeaderLayanan, JumlahAwal, Jumlah, JumlahHari, Harga, TipeDiskon, Diskon, ShareTipe, ShareRS, ShareDokter, ShareExternal, ShareLain, LoginBuat, TanggalBuat, NA) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'N')`, dispenserID, operationID, registration.RegID, packageID, service.DepartmentID, service.ServiceID, nullStringValue(service.HeaderService), nullStringValue(service.Quantity), nullInt64Value(service.Days), nullStringValue(service.Price), nullStringValue(service.DiscountType), nullStringValue(service.Discount), nullStringValue(service.ShareType), nullStringValue(service.ShareHospital), nullStringValue(service.ShareDoctor), nullStringValue(service.ShareExternal), nullStringValue(service.ShareOther), channel, now)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.LastInsertId()
|
||||
}
|
||||
|
||||
func insertPackageTransactionService(ctx context.Context, tx *sql.Tx, service resolvedLabService, discountType string, discount, patient, patientDefault, guarantor, guarantorDefault float64, packageID, regID string, trxDepartmentID int64, medrecID, departmentID, doctorID, classID, channel, now string) (int64, error) {
|
||||
headerDetail := 1
|
||||
if service.FlagTipe.Valid && service.FlagTipe.String == "M" {
|
||||
headerDetail = 0
|
||||
}
|
||||
result, err := tx.ExecContext(ctx, `INSERT INTO trxlayanan (TrxDepartemenID, RegID, MEDRECID, DepartemenID, DokterID, HeaderDetail, Urutan, LevelLayanan, HeaderLayananID, LayananID, LayananDepartemenID, PaketID, JenisRegID, KelasID, Catatan, Cito, MarkUpCito, Jumlah, Satuan, MataUang, Rate, UnitCost, TipeDiskon, Diskon, HargaDefault, Harga, DitanggungPasien, DitanggungPasienDefault, DitanggungPenjamin, DitanggungPenjaminDefault, ShareTipe, ShareRS, ShareDokter, ShareExternal, ShareLain2, Tagihkan, Tampilkan, LoginBuat, TanggalBuat, NA) VALUES (?, ?, ?, ?, ?, ?, 0, 0, '', ?, ?, ?, '3', ?, ?, ?, ?, ?, ?, 'IDR', 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'Y', 'Y', ?, ?, 'N')`, trxDepartmentID, regID, medrecID, departmentID, doctorID, headerDetail, service.LayananID, service.LayananDepartemenID, packageID, classID, service.Catatan, service.Cito, service.MarkupCito, service.Jumlah, nullStringValue(service.Satuan), service.UnitCost, discountType, discount, service.HargaDefault, service.Harga, patient, patientDefault, guarantor, guarantorDefault, service.ShareTipe, service.ShareRS, service.ShareDokter, service.ShareExternal, service.ShareLain, channel, now)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
id, err := result.LastInsertId()
|
||||
if err != nil || headerDetail != 0 {
|
||||
return id, err
|
||||
}
|
||||
if err := insertLabServiceSupport(ctx, tx, id, service.LayananID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
details, err := findMultipleLabDetails(ctx, tx, service.LayananID, departmentID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for _, detail := range details {
|
||||
if err := insertMultipleLabDetail(ctx, tx, detail, service, regID, trxDepartmentID, medrecID, departmentID, doctorID, classID, channel, now); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func insertPackageAdministration(ctx context.Context, tx *sql.Tx, dispenserID int64, regID, packageID, channel, now string) error {
|
||||
_, err := tx.ExecContext(ctx, `INSERT INTO paket_dispenser_adm (PaketAdmID, PaketDispenserID, RegID, PaketID, TipeRI, Hitung, AdmRI, AdmRIBaru, TipeDiskonRI, DiskonRI, LoginBuat, TanggalBuat, NA) SELECT PaketAdmID, ?, ?, PaketID, TipeRI, 'Y', AdmRI, AdmRIBaru, TipeDiskonRI, DiskonRI, ?, ?, 'N' FROM paket_adm WHERE PaketID = ? AND NA = 'N' LIMIT 1`, dispenserID, regID, channel, now, packageID)
|
||||
return err
|
||||
}
|
||||
|
||||
func applyPackageDiscount(price, discount float64, discountType string) float64 {
|
||||
if strings.EqualFold(discountType, "Percentage") {
|
||||
return math.Max(0, price-price*discount/100)
|
||||
}
|
||||
return math.Max(0, price-discount)
|
||||
}
|
||||
|
||||
func packageString(value sql.NullString, fallback string) string {
|
||||
if value.Valid && strings.TrimSpace(value.String) != "" {
|
||||
return value.String
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -21,7 +21,15 @@ type LabRegistrationParams struct {
|
||||
DoctorID string
|
||||
SenderDoctorID string
|
||||
Referrer string
|
||||
PatientType string
|
||||
TariffPeriodID int64
|
||||
CompanyID string
|
||||
InsuranceID string
|
||||
ProviderID string
|
||||
GroupTariffID int64
|
||||
PolicyNumber string
|
||||
EmployeeID string
|
||||
EmployeeName string
|
||||
ClassID string
|
||||
DailyShiftID *int64
|
||||
WorkingDiagnosis string
|
||||
@@ -43,6 +51,15 @@ type LabRegistrationData struct {
|
||||
Layanan []LabService `json:"layanan"`
|
||||
AddedLayananIDs *[]string `json:"added_layanan_ids,omitempty"`
|
||||
SkippedLayananIDs *[]string `json:"skipped_layanan_ids,omitempty"`
|
||||
Package *LabPackage `json:"package,omitempty"`
|
||||
}
|
||||
|
||||
type LabPackage struct {
|
||||
PaketDispenserID int64 `json:"PaketDispenserID,omitempty"`
|
||||
PaketID string `json:"PaketID"`
|
||||
Nama string `json:"Nama"`
|
||||
KunjunganKe int64 `json:"KunjunganKe,omitempty"`
|
||||
AddedLayananIDs []string `json:"added_layanan_ids,omitempty"`
|
||||
}
|
||||
|
||||
type LabRegistration struct {
|
||||
@@ -54,6 +71,11 @@ type LabRegistration struct {
|
||||
JenisPasienID int64 `json:"JenisPasienID"`
|
||||
JenisRegID *int64 `json:"JenisRegID"`
|
||||
PeriodeTarifID int64 `json:"PeriodeTarifID"`
|
||||
PerusahaanID *string `json:"PerusahaanID"`
|
||||
AsuransiID *string `json:"AsuransiID"`
|
||||
ProviderID *string `json:"ProviderID"`
|
||||
GroupTarifID *int64 `json:"GroupTarifID"`
|
||||
NomerPolis *string `json:"NomerPolis"`
|
||||
}
|
||||
|
||||
type LabTransaction struct {
|
||||
@@ -91,6 +113,11 @@ type dbLabRegistration struct {
|
||||
JenisPasienID int64
|
||||
JenisRegID sql.NullInt64
|
||||
PeriodeTarifID int64
|
||||
PerusahaanID sql.NullString
|
||||
AsuransiID sql.NullString
|
||||
ProviderID sql.NullString
|
||||
GroupTarifID sql.NullInt64
|
||||
NomerPolis sql.NullString
|
||||
}
|
||||
|
||||
type dbLabTransaction struct {
|
||||
@@ -161,6 +188,9 @@ func registerLabInTransaction(ctx context.Context, tx *sql.Tx, params LabRegistr
|
||||
if !departmentActive {
|
||||
return LabRegistrationOutcome{}, ValidationError{Message: "Departemen tujuan bukan departemen laboratorium aktif"}
|
||||
}
|
||||
if err := validateLabGuarantor(ctx, tx, params); err != nil {
|
||||
return LabRegistrationOutcome{}, err
|
||||
}
|
||||
|
||||
patient, found, err := findLabPatientForUpdate(ctx, tx, params.MedrecID)
|
||||
if err != nil {
|
||||
@@ -175,6 +205,15 @@ func registerLabInTransaction(ctx context.Context, tx *sql.Tx, params LabRegistr
|
||||
return LabRegistrationOutcome{}, err
|
||||
}
|
||||
if found {
|
||||
if !sameLabGuarantor(existing, params) {
|
||||
return labOutcome(
|
||||
409,
|
||||
false,
|
||||
"registration_guarantor_conflict",
|
||||
"Pasien sudah memiliki registrasi laboratorium pada hari yang sama dengan penjamin berbeda",
|
||||
nil,
|
||||
), nil
|
||||
}
|
||||
trx, trxFound, err := findLabTransaction(ctx, tx, existing.RegID, params.DepartmentID)
|
||||
if err != nil {
|
||||
return LabRegistrationOutcome{}, err
|
||||
@@ -223,10 +262,11 @@ func registerLabInTransaction(ctx context.Context, tx *sql.Tx, params LabRegistr
|
||||
INSERT INTO regpas (
|
||||
RegID, MEDRECID, Nama, ALAMATEXTPAS, TELPEXTPAS, TglLahir, JnsKelamin,
|
||||
KunjunganKe, Tanggal, JenisPasienID, PeriodeTarifID, PerusahaanID,
|
||||
GroupTarifID, AsuransiID, ProviderID, DepartemenID, Perujuk, JenisRegID,
|
||||
GroupTarifID, AsuransiID, ProviderID, NomerPolis, NIK, NamaKaryawan,
|
||||
DepartemenID, Perujuk, JenisRegID,
|
||||
DokterID, DokterID2, KelasID, MataUang, Rate, Pulang, BolehPulang, Lunas,
|
||||
BatalDaftar, Catatan, ShiftHarianIDDaftar, LoginBuat, TanggalBuat, NA
|
||||
) VALUES (?, ?, ?, '', '', ?, ?, ?, ?, '0', ?, '', '0', '', '', ?, ?, '3', ?, ?, ?, 'IDR', 1, 'N', 'N', 'N', 'N', ?, ?, ?, ?, 'N')`,
|
||||
) VALUES (?, ?, ?, '', '', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '3', ?, ?, ?, 'IDR', 1, 'N', 'N', 'N', 'N', ?, ?, ?, ?, 'N')`,
|
||||
regID,
|
||||
patient.MEDRECID,
|
||||
patient.Nama,
|
||||
@@ -234,7 +274,15 @@ func registerLabInTransaction(ctx context.Context, tx *sql.Tx, params LabRegistr
|
||||
nullStringValue(patient.Kelamin),
|
||||
previousVisits+1,
|
||||
nowText,
|
||||
params.PatientType,
|
||||
params.TariffPeriodID,
|
||||
params.CompanyID,
|
||||
params.GroupTariffID,
|
||||
params.InsuranceID,
|
||||
params.ProviderID,
|
||||
params.PolicyNumber,
|
||||
params.EmployeeID,
|
||||
params.EmployeeName,
|
||||
params.DepartmentID,
|
||||
strings.TrimSpace(params.Referrer),
|
||||
params.DoctorID,
|
||||
@@ -295,11 +343,108 @@ func registerLabInTransaction(ctx context.Context, tx *sql.Tx, params LabRegistr
|
||||
return labOutcome(201, true, "registered", "Registrasi laboratorium berhasil disimpan", &data), nil
|
||||
}
|
||||
|
||||
func validateLabGuarantor(ctx context.Context, tx *sql.Tx, params LabRegistrationParams) error {
|
||||
if params.PatientType == "0" {
|
||||
return nil
|
||||
}
|
||||
|
||||
active, err := exists(ctx, tx, `
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM jenispasien
|
||||
WHERE JenisPasienID = ? AND NA = 'N'
|
||||
)`, params.PatientType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !active {
|
||||
return ValidationError{Message: "Jenis pasien penjamin tidak ditemukan atau sudah tidak aktif"}
|
||||
}
|
||||
|
||||
guarantors := []struct {
|
||||
label string
|
||||
id string
|
||||
}{
|
||||
{label: "Perusahaan", id: params.CompanyID},
|
||||
{label: "Asuransi/penanggung", id: params.InsuranceID},
|
||||
{label: "Provider", id: params.ProviderID},
|
||||
}
|
||||
for _, guarantor := range guarantors {
|
||||
if guarantor.id == "" {
|
||||
continue
|
||||
}
|
||||
active, err = exists(ctx, tx, `
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM perusahaan
|
||||
WHERE PerusahaanID = ? AND NA = 'N'
|
||||
)`, guarantor.id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !active {
|
||||
return ValidationError{Message: guarantor.label + " tidak ditemukan atau sudah tidak aktif"}
|
||||
}
|
||||
}
|
||||
|
||||
var tariffPeriodID int64
|
||||
err = tx.QueryRowContext(ctx, `
|
||||
SELECT pg.PeriodeTarifID
|
||||
FROM perusahaan_grouptarif AS pg
|
||||
INNER JOIN perusahaan_tarif AS pt
|
||||
ON pt.Perusahaan_TarifID = pg.Perusahaan_TarifID
|
||||
WHERE pg.GroupTarifID = ?
|
||||
AND pt.PerusahaanID = ?
|
||||
AND pg.NA = 'N'
|
||||
AND DATE(pg.TanggalBerlaku) <= ?
|
||||
AND DATE(pg.TanggalBerakhir) >= ?
|
||||
LIMIT 1`,
|
||||
params.GroupTariffID,
|
||||
params.CompanyID,
|
||||
params.RegistrationDate.Format("2006-01-02"),
|
||||
params.RegistrationDate.Format("2006-01-02"),
|
||||
).Scan(&tariffPeriodID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ValidationError{Message: "Group tarif tidak aktif, tidak berlaku pada tanggal registrasi, atau bukan milik perusahaan yang dipilih"}
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tariffPeriodID != params.TariffPeriodID {
|
||||
return ValidationError{Message: fmt.Sprintf("Periode tarif harus %d sesuai group tarif yang dipilih", tariffPeriodID)}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func sameLabGuarantor(existing dbLabRegistration, params LabRegistrationParams) bool {
|
||||
return strconv.FormatInt(existing.JenisPasienID, 10) == params.PatientType &&
|
||||
existing.PeriodeTarifID == params.TariffPeriodID &&
|
||||
nullInt64(existing.GroupTarifID) == params.GroupTariffID &&
|
||||
strings.TrimSpace(nullableString(existing.PerusahaanID)) == params.CompanyID &&
|
||||
strings.TrimSpace(nullableString(existing.AsuransiID)) == params.InsuranceID &&
|
||||
strings.TrimSpace(nullableString(existing.ProviderID)) == params.ProviderID &&
|
||||
strings.TrimSpace(nullableString(existing.NomerPolis)) == params.PolicyNumber
|
||||
}
|
||||
|
||||
func nullInt64(value sql.NullInt64) int64 {
|
||||
if !value.Valid {
|
||||
return 0
|
||||
}
|
||||
return value.Int64
|
||||
}
|
||||
|
||||
func nullableString(value sql.NullString) string {
|
||||
if !value.Valid {
|
||||
return ""
|
||||
}
|
||||
return value.String
|
||||
}
|
||||
|
||||
func findLabRegistrationByID(ctx context.Context, tx *sql.Tx, regID string) (dbLabRegistration, error) {
|
||||
var registration dbLabRegistration
|
||||
err := tx.QueryRowContext(ctx, `
|
||||
SELECT RegID, MEDRECID, Tanggal, DepartemenID, DokterID,
|
||||
JenisPasienID, JenisRegID, PeriodeTarifID
|
||||
JenisPasienID, JenisRegID, PeriodeTarifID, PerusahaanID,
|
||||
AsuransiID, ProviderID, GroupTarifID, NomerPolis
|
||||
FROM regpas WHERE RegID = ? LIMIT 1`, regID).Scan(
|
||||
®istration.RegID,
|
||||
®istration.MEDRECID,
|
||||
@@ -309,6 +454,11 @@ func findLabRegistrationByID(ctx context.Context, tx *sql.Tx, regID string) (dbL
|
||||
®istration.JenisPasienID,
|
||||
®istration.JenisRegID,
|
||||
®istration.PeriodeTarifID,
|
||||
®istration.PerusahaanID,
|
||||
®istration.AsuransiID,
|
||||
®istration.ProviderID,
|
||||
®istration.GroupTarifID,
|
||||
®istration.NomerPolis,
|
||||
)
|
||||
return registration, err
|
||||
}
|
||||
@@ -355,7 +505,8 @@ func findExistingLabRegistrationForUpdate(
|
||||
var registration dbLabRegistration
|
||||
err := tx.QueryRowContext(ctx, `
|
||||
SELECT RegID, MEDRECID, Tanggal, DepartemenID, DokterID,
|
||||
JenisPasienID, JenisRegID, PeriodeTarifID
|
||||
JenisPasienID, JenisRegID, PeriodeTarifID, PerusahaanID,
|
||||
AsuransiID, ProviderID, GroupTarifID, NomerPolis
|
||||
FROM regpas
|
||||
WHERE MEDRECID = ?
|
||||
AND DepartemenID = ?
|
||||
@@ -371,6 +522,11 @@ func findExistingLabRegistrationForUpdate(
|
||||
®istration.JenisPasienID,
|
||||
®istration.JenisRegID,
|
||||
®istration.PeriodeTarifID,
|
||||
®istration.PerusahaanID,
|
||||
®istration.AsuransiID,
|
||||
®istration.ProviderID,
|
||||
®istration.GroupTarifID,
|
||||
®istration.NomerPolis,
|
||||
)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return dbLabRegistration{}, false, nil
|
||||
@@ -544,6 +700,11 @@ func buildLabRegistrationData(ctx context.Context, tx *sql.Tx, registration dbLa
|
||||
JenisPasienID: registration.JenisPasienID,
|
||||
JenisRegID: nullInt64Ptr(registration.JenisRegID),
|
||||
PeriodeTarifID: registration.PeriodeTarifID,
|
||||
PerusahaanID: nullStringPtr(registration.PerusahaanID),
|
||||
AsuransiID: nullStringPtr(registration.AsuransiID),
|
||||
ProviderID: nullStringPtr(registration.ProviderID),
|
||||
GroupTarifID: nullInt64Ptr(registration.GroupTarifID),
|
||||
NomerPolis: nullStringPtr(registration.NomerPolis),
|
||||
},
|
||||
Layanan: make([]LabService, 0),
|
||||
}
|
||||
|
||||
@@ -261,7 +261,8 @@ func findLabRegistrationStateForUpdate(ctx context.Context, tx *sql.Tx, regID st
|
||||
var state labRegistrationServiceState
|
||||
err := tx.QueryRowContext(ctx, `
|
||||
SELECT RegID, MEDRECID, Tanggal, DepartemenID, DokterID,
|
||||
JenisPasienID, JenisRegID, PeriodeTarifID, KelasID, BatalDaftar, Pulang
|
||||
JenisPasienID, JenisRegID, PeriodeTarifID, PerusahaanID, AsuransiID,
|
||||
ProviderID, GroupTarifID, NomerPolis, KelasID, BatalDaftar, Pulang
|
||||
FROM regpas WHERE RegID = ? AND NA = 'N' LIMIT 1 FOR UPDATE`, regID).Scan(
|
||||
&state.Registration.RegID,
|
||||
&state.Registration.MEDRECID,
|
||||
@@ -271,6 +272,11 @@ func findLabRegistrationStateForUpdate(ctx context.Context, tx *sql.Tx, regID st
|
||||
&state.Registration.JenisPasienID,
|
||||
&state.Registration.JenisRegID,
|
||||
&state.Registration.PeriodeTarifID,
|
||||
&state.Registration.PerusahaanID,
|
||||
&state.Registration.AsuransiID,
|
||||
&state.Registration.ProviderID,
|
||||
&state.Registration.GroupTarifID,
|
||||
&state.Registration.NomerPolis,
|
||||
&state.KelasID,
|
||||
&state.BatalDaftar,
|
||||
&state.Pulang,
|
||||
|
||||
326
internal/repository/laporan_mcu.go
Normal file
326
internal/repository/laporan_mcu.go
Normal file
@@ -0,0 +1,326 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
// LaporanMCUParams is the date range used by docs/lap_mcu.php. The bounds are
|
||||
// deliberately second-based because the legacy report queried
|
||||
// "tanggal 00:00:01" through "tanggal2 23:59:59".
|
||||
type LaporanMCUParams struct {
|
||||
Tanggal string
|
||||
Tanggal2 string
|
||||
}
|
||||
|
||||
// LaporanMCURow contains the columns that the legacy Excel report writes.
|
||||
// Nullable values remain pointers so an absent HIS record is represented as
|
||||
// null by the API, while values for which the PHP report supplied a fallback
|
||||
// are always non-null.
|
||||
type LaporanMCURow struct {
|
||||
No int `json:"No"`
|
||||
RegID *string `json:"RegID"`
|
||||
MEDRECID *string `json:"MEDRECID"`
|
||||
NamaPasien *string `json:"NamaPasien"`
|
||||
JenisKelamin *string `json:"JenisKelamin"`
|
||||
TanggalLahir *string `json:"TanggalLahir"`
|
||||
Umur *int64 `json:"Umur"`
|
||||
TanggalMCU *string `json:"TanggalMCU"`
|
||||
NamaPenanggung *string `json:"NamaPenanggung"`
|
||||
DokterMCU *string `json:"DokterMCU"`
|
||||
InstansiKerja *string `json:"InstansiKerja"`
|
||||
TinggiBadan *string `json:"TinggiBadan"`
|
||||
BeratBadan *string `json:"BeratBadan"`
|
||||
TekananDarah *string `json:"TekananDarah"`
|
||||
Nadi *string `json:"Nadi"`
|
||||
Suhu *string `json:"Suhu"`
|
||||
Pernafasan *string `json:"Pernafasan"`
|
||||
Diabetes *string `json:"Diabetes"`
|
||||
Hipertensi *string `json:"Hipertensi"`
|
||||
Dyslipidemia *string `json:"Dyslipidemia"`
|
||||
Jantung *string `json:"Jantung"`
|
||||
Stroke *string `json:"Stroke"`
|
||||
Asma *string `json:"Asma"`
|
||||
Paru *string `json:"Paru"`
|
||||
RiwayatCidera *string `json:"RiwayatCidera"`
|
||||
RiwayatRawatInap *string `json:"RiwayatRawatInap"`
|
||||
RiwayatOperasi *string `json:"RiwayatOperasi"`
|
||||
LainLain *string `json:"LainLain"`
|
||||
DiabetesKeluarga *string `json:"DiabetesKeluarga"`
|
||||
HipertensiKeluarga *string `json:"HipertensiKeluarga"`
|
||||
DyslipidemiaKeluarga *string `json:"DyslipidemiaKeluarga"`
|
||||
JantungKeluarga *string `json:"JantungKeluarga"`
|
||||
StrokeKeluarga *string `json:"StrokeKeluarga"`
|
||||
AsmaKeluarga *string `json:"AsmaKeluarga"`
|
||||
ParuKeluarga *string `json:"ParuKeluarga"`
|
||||
RiwayatCideraKeluarga *string `json:"RiwayatCideraKeluarga"`
|
||||
RiwayatRawatInapKeluarga *string `json:"RiwayatRawatInapKeluarga"`
|
||||
RiwayatOperasiKeluarga *string `json:"RiwayatOperasiKeluarga"`
|
||||
LainLainKeluarga *string `json:"LainLainKeluarga"`
|
||||
AlergiObat *string `json:"AlergiObat"`
|
||||
AlergiMakanan *string `json:"AlergiMakanan"`
|
||||
AlergiLain *string `json:"AlergiLain"`
|
||||
Merokok *string `json:"Merokok"`
|
||||
Alkohol *string `json:"Alkohol"`
|
||||
Olahraga *string `json:"Olahraga"`
|
||||
KeadaanUmum *string `json:"KeadaanUmum"`
|
||||
KeadaanSakit *string `json:"KeadaanSakit"`
|
||||
StatusGizi *string `json:"StatusGizi"`
|
||||
Kesadaran *string `json:"Kesadaran"`
|
||||
ColourBlindness *string `json:"ColourBlindness"`
|
||||
VisusDekat *string `json:"VisusDekat"`
|
||||
KelainananMataLain *string `json:"KelainananMataLain"`
|
||||
Faring *string `json:"Faring"`
|
||||
Tonsil *string `json:"Tonsil"`
|
||||
MulutLainLain *string `json:"MulutLainLain"`
|
||||
KelainanGigi *string `json:"KelainanGigi"`
|
||||
ParuLain *string `json:"ParuLain"`
|
||||
JantungLain *string `json:"JantungLain"`
|
||||
Hernia *string `json:"Hernia"`
|
||||
KeluhanUtama *string `json:"KeluhanUtama"`
|
||||
PenyakitSekarang *string `json:"PenyakitSekarang"`
|
||||
PenyakitDahulu *string `json:"PenyakitDahulu"`
|
||||
ECG *string `json:"ECG"`
|
||||
Audiometri *string `json:"Audiometri"`
|
||||
Spirometri *string `json:"Spirometri"`
|
||||
PemeriksaanLainLain *string `json:"PemeriksaanLainLain"`
|
||||
SaranKesehatanKerja *string `json:"SaranKesehatanKerja"`
|
||||
Kebisingan *string `json:"Kebisingan"`
|
||||
SuhuPanas *string `json:"SuhuPanas"`
|
||||
SuhuDingin *string `json:"SuhuDingin"`
|
||||
RadiasiBukanPengion *string `json:"RadiasiBukanPengion"`
|
||||
RadiasiPengion *string `json:"RadiasiPengion"`
|
||||
GetaranLokal *string `json:"GetaranLokal"`
|
||||
GetaranSeluruhTubuh *string `json:"GetaranSeluruhTubuh"`
|
||||
Ketinggian *string `json:"Ketinggian"`
|
||||
LainLainAkibatKerja *string `json:"LainLainAkibatKerja"`
|
||||
DebuOrganik *string `json:"DebuOrganik"`
|
||||
DebuAnorganik *string `json:"DebuAnorganik"`
|
||||
Asap *string `json:"Asap"`
|
||||
LogamBerat *string `json:"LogamBerat"`
|
||||
PelarutOrganik *string `json:"PelarutOrganik"`
|
||||
IritanAsam *string `json:"IritanAsam"`
|
||||
IritanBasa *string `json:"IritanBasa"`
|
||||
CairanPembersih *string `json:"CairanPembersih"`
|
||||
Pestisida *string `json:"Pestisida"`
|
||||
UapLogam *string `json:"UapLogam"`
|
||||
BahayaKimiaLain *string `json:"BahayaKimiaLain"`
|
||||
Bakteri *string `json:"Bakteri"`
|
||||
CairanTubuh *string `json:"CairanTubuh"`
|
||||
Serangga *string `json:"Serangga"`
|
||||
Limbah *string `json:"Limbah"`
|
||||
BiologiLain *string `json:"BiologiLain"`
|
||||
Overload *string `json:"Overload"`
|
||||
Mismatch *string `json:"Mismatch"`
|
||||
KetidakJelasan *string `json:"KetidakJelasan"`
|
||||
HambatanJenjangKarir *string `json:"HambatanJenjangKarir"`
|
||||
KerjaShift *string `json:"KerjaShift"`
|
||||
KonflikTeman *string `json:"KonflikTeman"`
|
||||
KonflikKeluarga *string `json:"KonflikKeluarga"`
|
||||
PsikologisLain *string `json:"PsikologisLain"`
|
||||
TanganBergerakUlang *string `json:"TanganBergerakUlang"`
|
||||
AngkatBerat *string `json:"AngkatBerat"`
|
||||
DudukLama *string `json:"DudukLama"`
|
||||
BerdiriLama *string `json:"BerdiriLama"`
|
||||
PosisiTubuhTidakErgonomis *string `json:"PosisiTubuhTidakErgonomis"`
|
||||
PencahayaanTidakSesuai *string `json:"PencahayaanTidakSesuai"`
|
||||
LihatMonitorLama *string `json:"LihatMonitorLama"`
|
||||
ErgonomisLain *string `json:"ErgonomisLain"`
|
||||
VisusJauhTanpaKoreksiVOD *string `json:"VisusJauhTanpaKoreksiVOD"`
|
||||
VisusJauhTanpaKoreksiVOS *string `json:"VisusJauhTanpaKoreksiVOS"`
|
||||
VisusJauhDenganKoreksiVOD *string `json:"VisusJauhDenganKoreksiVOD"`
|
||||
VisusJauhDenganKoreksiVOS *string `json:"VisusJauhDenganKoreksiVOS"`
|
||||
Pekerjaan *string `json:"Pekerjaan"`
|
||||
DokterPemeriksa *string `json:"DokterPemeriksa"`
|
||||
}
|
||||
|
||||
func (r MySQLLayananRepository) ListLaporanMCU(ctx context.Context, params LaporanMCUParams) ([]LaporanMCURow, error) {
|
||||
rows, err := r.db.QueryContext(ctx, laporanMCUQuery, params.Tanggal+" 00:00:01", params.Tanggal2+" 23:59:59")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
result := make([]LaporanMCURow, 0)
|
||||
for rows.Next() {
|
||||
row, err := scanLaporanMCURow(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
row.No = len(result) + 1
|
||||
result = append(result, row)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
const laporanMCUQuery = `
|
||||
SELECT
|
||||
r.RegID, r.MEDRECID, r.Nama,
|
||||
CASE WHEN m.Kelamin = 'L' THEN 'Laki - Laki' ELSE 'Perempuan' END,
|
||||
DATE_FORMAT(m.TanggalLahir, '%Y-%m-%d'), TIMESTAMPDIFF(YEAR, m.TanggalLahir, CURDATE()),
|
||||
DATE_FORMAT(r.Tanggal, '%Y-%m-%d %H:%i:%s'),
|
||||
COALESCE(NULLIF(p.Nama, ''), 'UMUM'), d.Nama, pm.DepartemenKerja,
|
||||
rd.TinggiBadan, rd.BeratBadan, tv.Tekanandarah, tv.Nadi, tv.Suhu, tv.Pernafasan,
|
||||
COALESCE(NULLIF(rpd.DiabetesDahuluAda, ''), 'Tidak Ada'),
|
||||
COALESCE(NULLIF(rpd.HipertensiDahuluAda, ''), 'Tidak Ada'),
|
||||
COALESCE(NULLIF(rpd.KelebihanLemakDahuluAda, ''), 'Tidak Ada'),
|
||||
COALESCE(NULLIF(rpd.JantungDahuluAda, ''), 'Tidak Ada'),
|
||||
COALESCE(NULLIF(rpd.StrokeDahuluAda, ''), 'Tidak Ada'),
|
||||
COALESCE(NULLIF(rpd.AsmaDahuluAda, ''), 'Tidak Ada'),
|
||||
COALESCE(NULLIF(rpd.ParuDahuluAda, ''), 'Tidak Ada'),
|
||||
COALESCE(NULLIF(rpd.CideraDahuluAda, ''), 'Tidak Ada'),
|
||||
COALESCE(NULLIF(rpd.RawatInapDahuluAda, ''), 'Tidak Ada'),
|
||||
COALESCE(NULLIF(rpd.OperasiDahuluAda, ''), 'Tidak Ada'),
|
||||
COALESCE(NULLIF(rpd.LainLainDahuluAda, ''), 'Tidak Ada'),
|
||||
COALESCE(NULLIF(rpk.DiabetesAda, ''), 'Tidak Ada'),
|
||||
COALESCE(NULLIF(rpk.HypertensiAda, ''), 'Tidak Ada'),
|
||||
COALESCE(NULLIF(rpk.KelebihanLemakAda, ''), 'Tidak Ada'),
|
||||
COALESCE(NULLIF(rpk.JantungAda, ''), 'Tidak Ada'),
|
||||
COALESCE(NULLIF(rpk.StrokeAda, ''), 'Tidak Ada'),
|
||||
COALESCE(NULLIF(rpk.AsmaAda, ''), 'Tidak Ada'),
|
||||
COALESCE(NULLIF(rpk.ParuAda, ''), 'Tidak Ada'),
|
||||
COALESCE(NULLIF(rpk.CideraAda, ''), 'Tidak Ada'),
|
||||
COALESCE(NULLIF(rpk.RawatInapAda, ''), 'Tidak Ada'),
|
||||
COALESCE(NULLIF(rpk.OperasiAda, ''), 'Tidak Ada'),
|
||||
COALESCE(NULLIF(rpk.LainLainAda, ''), 'Tidak Ada'),
|
||||
COALESCE(NULLIF(ra.AlergiObatAda, ''), 'Tidak Ada'),
|
||||
COALESCE(NULLIF(ra.AlergiMakananAda, ''), 'Tidak Ada'),
|
||||
COALESCE(NULLIF(ra.AlergiLainAda, ''), 'Tidak Ada'),
|
||||
COALESCE(NULLIF(gl.SejakKapanMerokok, ''), 'Tidak Merokok'),
|
||||
COALESCE(NULLIF(gl.SejakKapanAlkohol, ''), 'Tidak Minum ALkohol'),
|
||||
COALESCE(NULLIF(gl.KebiasaanOlahragaAda, ''), 'Tidak Olahraga'),
|
||||
pfu.KeadaanUmum, pfu.KeadaanSakit, pfu.StatusGizi, pfu.Kesadaran,
|
||||
pmata.ColourBlindness,
|
||||
COALESCE(NULLIF(pmata.VisusDekatKelainan, ''), 'Normal'),
|
||||
COALESCE(NULLIF(pmata.MataLainLainAda, ''), 'Tidak Ada'),
|
||||
COALESCE(NULLIF(ptg.FaringKelainan, ''), 'Normal'),
|
||||
COALESCE(NULLIF(ptg.TonsilKelainan, ''), 'Tidak Ada'),
|
||||
COALESCE(NULLIF(pmulut.MulutLainLainAda, ''), 'Tidak Ada'),
|
||||
COALESCE(NULLIF(pgigi.KelainanGigiAda, ''), 'Tidak Ada'),
|
||||
COALESCE(NULLIF(pparu.ParuLainLainAda, ''), 'Tidak Ada'),
|
||||
COALESCE(NULLIF(pjantung.JantungLainLainAda, ''), 'Tidak Ada'),
|
||||
COALESCE(NULLIF(pabd.HerniaAda, ''), 'Tidak Ada'),
|
||||
pm.KeluhanUtamaRingkasan, pm.PenyakitSekarangRingkasan, pm.PenyakitDahuluRingkasan,
|
||||
ph.ECG, ph.Audiometri, ph.Spirometri, pm.PemeriksaanLainLainRingkasan,
|
||||
pm.SaranKesehatanKerjaRingkasan,
|
||||
COALESCE(NULLIF(pak.KebisinganAda, ''), 'TidakAda'),
|
||||
COALESCE(NULLIF(pak.SuhuPanasAda, ''), 'TidakAda'),
|
||||
COALESCE(NULLIF(pak.SuhuDinginAda, ''), 'TidakAda'),
|
||||
COALESCE(NULLIF(pak.RadiasiBukanPengionAda, ''), 'TidakAda'),
|
||||
COALESCE(NULLIF(pak.RadiasiPengionAda, ''), 'TidakAda'),
|
||||
COALESCE(NULLIF(pak.GetaranLokalAda, ''), 'TidakAda'),
|
||||
COALESCE(NULLIF(pak.GetaranSeluruhTubuhAda, ''), 'TidakAda'),
|
||||
COALESCE(NULLIF(pak.KetinggianAda, ''), 'TidakAda'),
|
||||
COALESCE(NULLIF(pak.FisikLainLainAda, ''), 'TidakAda'),
|
||||
COALESCE(NULLIF(pak.DebuOrganikAda, ''), 'TidakAda'),
|
||||
COALESCE(NULLIF(pak.DebuAnorganikAda, ''), 'TidakAda'),
|
||||
COALESCE(NULLIF(pak.AsapAda, ''), 'TidakAda'),
|
||||
COALESCE(NULLIF(pak.LogamBeratAda, ''), 'TidakAda'),
|
||||
COALESCE(NULLIF(pak.PelarutOrganikAda, ''), 'TidakAda'),
|
||||
COALESCE(NULLIF(pak.IritanAsamAda, ''), 'TidakAda'),
|
||||
COALESCE(NULLIF(pak.IritanBasaAda, ''), 'TidakAda'),
|
||||
COALESCE(NULLIF(pak.CairanPembersihAda, ''), 'TidakAda'),
|
||||
COALESCE(NULLIF(pak.PestisidaAda, ''), 'TidakAda'),
|
||||
COALESCE(NULLIF(pak.UapLogamAda, ''), 'TidakAda'),
|
||||
COALESCE(NULLIF(pak.BahanKimiaBerbahayaLainLainAda, ''), 'TidakAda'),
|
||||
COALESCE(NULLIF(pak.BakteriAda, ''), 'TidakAda'),
|
||||
COALESCE(NULLIF(pak.CairanTubuhAda, ''), 'TidakAda'),
|
||||
COALESCE(NULLIF(pak.SeranggaAda, ''), 'TidakAda'),
|
||||
COALESCE(NULLIF(pak.LimbahAda, ''), 'TidakAda'),
|
||||
COALESCE(NULLIF(pak.BiologiLainLainAda, ''), 'TidakAda'),
|
||||
COALESCE(NULLIF(pak.OverLoadKerjaanAda, ''), 'TidakAda'),
|
||||
COALESCE(NULLIF(pak.MismatchKerjaanAda, ''), 'TidakAda'),
|
||||
COALESCE(NULLIF(pak.KetidakjelasanTugasAda, ''), 'TidakAda'),
|
||||
COALESCE(NULLIF(pak.HambatanJenjangKarirAda, ''), 'TidakAda'),
|
||||
COALESCE(NULLIF(pak.KerjaShiftAda, ''), 'TidakAda'),
|
||||
COALESCE(NULLIF(pak.KonflikTemanKerjaAda, ''), 'TidakAda'),
|
||||
COALESCE(NULLIF(pak.KonflikKeluargaAda, ''), 'TidakAda'),
|
||||
COALESCE(NULLIF(pak.PsikologisLainLainAda, ''), 'TidakAda'),
|
||||
COALESCE(NULLIF(pak.TanganBergerakBerulangAda, ''), 'TidakAda'),
|
||||
COALESCE(NULLIF(pak.AngkatBebanBeratAda, ''), 'TidakAda'),
|
||||
COALESCE(NULLIF(pak.DudukLamaAda, ''), 'TidakAda'),
|
||||
COALESCE(NULLIF(pak.BerdiriLamaAda, ''), 'TidakAda'),
|
||||
COALESCE(NULLIF(pak.PosisiTubuhTidakErgonomisAda, ''), 'TidakAda'),
|
||||
COALESCE(NULLIF(pak.PencahayaanTidakSesuaiAda, ''), 'TidakAda'),
|
||||
COALESCE(NULLIF(pak.LihatMonitorLamaAda, ''), 'TidakAda'),
|
||||
COALESCE(NULLIF(pak.ErgonomisLainLainAda, ''), 'TidakAda'),
|
||||
pmata.VisusJauhTanpaKoreksiVOD, pmata.VisusJauhTanpaKoreksiVOS,
|
||||
pmata.VisusJauhDenganKoreksiVOD, pmata.VisusJauhDenganKoreksiVOS,
|
||||
m.Pekerjaan, dp.Nama
|
||||
FROM regpas AS r
|
||||
LEFT JOIN medrec AS m ON m.MEDRECID = r.MEDRECID
|
||||
LEFT JOIN pengkajianmcu AS pm ON pm.RegID = r.RegID
|
||||
LEFT JOIN dokter AS d ON d.DokterID = r.DokterID
|
||||
LEFT JOIN dokter AS dp ON dp.DokterID = pm.DokterIDPengkaji
|
||||
LEFT JOIN perusahaan AS p ON p.PerusahaanID = r.AsuransiID
|
||||
LEFT JOIN tandavital_pasien AS tv ON tv.RegID = r.RegID AND tv.TekananDarah NOT LIKE ''
|
||||
LEFT JOIN regpas_detail AS rd ON rd.RegID = r.RegID
|
||||
LEFT JOIN gayahidup AS gl ON gl.RegID = r.RegID
|
||||
LEFT JOIN riwayatpenyakitdahulu AS rpd ON rpd.RegID = r.RegID
|
||||
LEFT JOIN riwayatpenyakitkeluarga AS rpk ON rpk.RegID = r.RegID
|
||||
LEFT JOIN riwayatalergi AS ra ON ra.MEDRECID = r.MEDRECID
|
||||
LEFT JOIN pemeriksaanfisik_umum AS pfu ON pfu.RegID = r.RegID
|
||||
LEFT JOIN pemeriksaanfisik_mata AS pmata ON pmata.RegID = r.RegID
|
||||
LEFT JOIN pemeriksaanfisik_tenggorokan AS ptg ON ptg.RegID = r.RegID
|
||||
LEFT JOIN pemeriksaanfisik_mulut AS pmulut ON pmulut.RegID = r.RegID
|
||||
LEFT JOIN pemeriksaanfisik_gigi AS pgigi ON pgigi.RegID = r.RegID
|
||||
LEFT JOIN pemeriksaanfisik_paru AS pparu ON pparu.RegID = r.RegID
|
||||
LEFT JOIN pemeriksaanfisik_jantung AS pjantung ON pjantung.RegID = r.RegID
|
||||
LEFT JOIN pemeriksaanfisik_abdomen AS pabd ON pabd.RegID = r.RegID
|
||||
LEFT JOIN pemeriksaankhusus AS ph ON ph.RegID = r.RegID
|
||||
LEFT JOIN penyakitakibatkerja AS pak ON pak.RegID = r.RegID
|
||||
WHERE r.Tanggal BETWEEN ? AND ? AND r.JenisRegID = '2' AND r.NA = 'N'
|
||||
ORDER BY r.Tanggal, r.RegID`
|
||||
|
||||
func scanLaporanMCURow(scanner rowScanner) (LaporanMCURow, error) {
|
||||
var row LaporanMCURow
|
||||
var regID, medrecID, nama, jenisKelamin, tanggalLahir, tanggalMCU, namaPenanggung, dokterMCU, instansiKerja sql.NullString
|
||||
var umur sql.NullInt64
|
||||
var fields [103]sql.NullString
|
||||
args := []any{
|
||||
®ID, &medrecID, &nama, &jenisKelamin, &tanggalLahir, &umur, &tanggalMCU,
|
||||
&namaPenanggung, &dokterMCU, &instansiKerja,
|
||||
}
|
||||
for i := range fields {
|
||||
args = append(args, &fields[i])
|
||||
}
|
||||
if err := scanner.Scan(args...); err != nil {
|
||||
return LaporanMCURow{}, err
|
||||
}
|
||||
|
||||
row.RegID = nullStringPtr(regID)
|
||||
row.MEDRECID = nullStringPtr(medrecID)
|
||||
row.NamaPasien = nullStringPtr(nama)
|
||||
row.JenisKelamin = nullStringPtr(jenisKelamin)
|
||||
row.TanggalLahir = nullStringPtr(tanggalLahir)
|
||||
row.Umur = nullInt64Ptr(umur)
|
||||
row.TanggalMCU = nullStringPtr(tanggalMCU)
|
||||
row.NamaPenanggung = nullStringPtr(namaPenanggung)
|
||||
row.DokterMCU = nullStringPtr(dokterMCU)
|
||||
row.InstansiKerja = nullStringPtr(instansiKerja)
|
||||
|
||||
values := make([]*string, len(fields))
|
||||
for i := range fields {
|
||||
values[i] = nullStringPtr(fields[i])
|
||||
}
|
||||
assignLaporanMCUFields(&row, values)
|
||||
return row, nil
|
||||
}
|
||||
|
||||
func assignLaporanMCUFields(row *LaporanMCURow, v []*string) {
|
||||
// The SELECT order intentionally mirrors the legacy spreadsheet column
|
||||
// order after the ten patient/header columns above.
|
||||
row.TinggiBadan, row.BeratBadan, row.TekananDarah, row.Nadi, row.Suhu, row.Pernafasan = v[0], v[1], v[2], v[3], v[4], v[5]
|
||||
row.Diabetes, row.Hipertensi, row.Dyslipidemia, row.Jantung, row.Stroke, row.Asma, row.Paru, row.RiwayatCidera, row.RiwayatRawatInap, row.RiwayatOperasi, row.LainLain = v[6], v[7], v[8], v[9], v[10], v[11], v[12], v[13], v[14], v[15], v[16]
|
||||
row.DiabetesKeluarga, row.HipertensiKeluarga, row.DyslipidemiaKeluarga, row.JantungKeluarga, row.StrokeKeluarga, row.AsmaKeluarga, row.ParuKeluarga, row.RiwayatCideraKeluarga, row.RiwayatRawatInapKeluarga, row.RiwayatOperasiKeluarga, row.LainLainKeluarga = v[17], v[18], v[19], v[20], v[21], v[22], v[23], v[24], v[25], v[26], v[27]
|
||||
row.AlergiObat, row.AlergiMakanan, row.AlergiLain, row.Merokok, row.Alkohol, row.Olahraga = v[28], v[29], v[30], v[31], v[32], v[33]
|
||||
row.KeadaanUmum, row.KeadaanSakit, row.StatusGizi, row.Kesadaran, row.ColourBlindness, row.VisusDekat, row.KelainananMataLain = v[34], v[35], v[36], v[37], v[38], v[39], v[40]
|
||||
row.Faring, row.Tonsil, row.MulutLainLain, row.KelainanGigi, row.ParuLain, row.JantungLain, row.Hernia = v[41], v[42], v[43], v[44], v[45], v[46], v[47]
|
||||
row.KeluhanUtama, row.PenyakitSekarang, row.PenyakitDahulu, row.ECG, row.Audiometri, row.Spirometri, row.PemeriksaanLainLain, row.SaranKesehatanKerja = v[48], v[49], v[50], v[51], v[52], v[53], v[54], v[55]
|
||||
row.Kebisingan, row.SuhuPanas, row.SuhuDingin, row.RadiasiBukanPengion, row.RadiasiPengion, row.GetaranLokal, row.GetaranSeluruhTubuh, row.Ketinggian, row.LainLainAkibatKerja, row.DebuOrganik, row.DebuAnorganik, row.Asap, row.LogamBerat, row.PelarutOrganik, row.IritanAsam, row.IritanBasa, row.CairanPembersih, row.Pestisida, row.UapLogam, row.BahayaKimiaLain = v[56], v[57], v[58], v[59], v[60], v[61], v[62], v[63], v[64], v[65], v[66], v[67], v[68], v[69], v[70], v[71], v[72], v[73], v[74], v[75]
|
||||
row.Bakteri, row.CairanTubuh, row.Serangga, row.Limbah, row.BiologiLain, row.Overload, row.Mismatch, row.KetidakJelasan, row.HambatanJenjangKarir, row.KerjaShift, row.KonflikTeman, row.KonflikKeluarga, row.PsikologisLain, row.TanganBergerakUlang, row.AngkatBerat, row.DudukLama, row.BerdiriLama, row.PosisiTubuhTidakErgonomis, row.PencahayaanTidakSesuai, row.LihatMonitorLama, row.ErgonomisLain = v[76], v[77], v[78], v[79], v[80], v[81], v[82], v[83], v[84], v[85], v[86], v[87], v[88], v[89], v[90], v[91], v[92], v[93], v[94], v[95], v[96]
|
||||
row.VisusJauhTanpaKoreksiVOD, row.VisusJauhTanpaKoreksiVOS, row.VisusJauhDenganKoreksiVOD, row.VisusJauhDenganKoreksiVOS, row.Pekerjaan, row.DokterPemeriksa = v[97], v[98], v[99], v[100], v[101], v[102]
|
||||
}
|
||||
78
internal/repository/layanan.go
Normal file
78
internal/repository/layanan.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
type LayananListParams struct {
|
||||
Page int
|
||||
PerPage int
|
||||
Search string
|
||||
DepartemenID string
|
||||
}
|
||||
|
||||
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"`
|
||||
LayananCode *string `json:"LayananCode"`
|
||||
}
|
||||
|
||||
func (r MySQLLayananRepository) ListLayanan(ctx context.Context, params LayananListParams) (LayananPage, error) {
|
||||
var total int
|
||||
if err := r.db.QueryRowContext(ctx, `
|
||||
SELECT COUNT(*)
|
||||
FROM layanandepartemen ld
|
||||
LEFT JOIN masterlayanan ml ON ml.LayananID = ld.LayananID
|
||||
WHERE ld.NA = 'N'
|
||||
AND (? = '' OR ld.DepartemenID = ?)
|
||||
AND (? = '' OR ld.LayananID LIKE ? OR ml.LayananKode LIKE ? OR ml.Nama LIKE ?)
|
||||
`, params.DepartemenID, params.DepartemenID, params.Search, "%"+params.Search+"%", "%"+params.Search+"%", "%"+params.Search+"%").Scan(&total); err != nil {
|
||||
return LayananPage{}, err
|
||||
}
|
||||
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT ld.GroupLayananID, d.Nama, ld.LayananDepartemenID,
|
||||
ld.LayananID, ml.LayananKode, ml.Nama
|
||||
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 (? = '' OR ld.DepartemenID = ?)
|
||||
AND (? = '' OR ld.LayananID LIKE ? OR ml.LayananKode LIKE ? OR ml.Nama LIKE ?)
|
||||
ORDER BY ld.TanggalBuat DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`, params.DepartemenID, params.DepartemenID, params.Search, "%"+params.Search+"%", "%"+params.Search+"%", "%"+params.Search+"%", 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, layananCode, namaLayanan sql.NullString
|
||||
if err := rows.Scan(&groupLayananID, &departemen, &item.LayananDepartemenID,
|
||||
&layananID, &layananCode, &namaLayanan); err != nil {
|
||||
return LayananPage{}, err
|
||||
}
|
||||
item.GroupLayananID = nullStringPtr(groupLayananID)
|
||||
item.Departemen = nullStringPtr(departemen)
|
||||
item.LayananID = nullStringPtr(layananID)
|
||||
item.LayananCode = nullStringPtr(layananCode)
|
||||
item.NamaLayanan = nullStringPtr(namaLayanan)
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return LayananPage{}, err
|
||||
}
|
||||
return LayananPage{Items: items, Total: total}, nil
|
||||
}
|
||||
@@ -26,6 +26,9 @@ type MedrecParams struct {
|
||||
Village string
|
||||
PostalCode string
|
||||
CompanyID string
|
||||
EmployeeID string
|
||||
DependantID string
|
||||
EmployeeName string
|
||||
Channel string
|
||||
}
|
||||
|
||||
@@ -49,6 +52,7 @@ type PossibleDuplicateData struct {
|
||||
type MedrecPatient struct {
|
||||
MEDRECID string `json:"MEDRECID"`
|
||||
Nama *string `json:"Nama"`
|
||||
Kelamin *string `json:"-"`
|
||||
TanggalLahir *string `json:"TanggalLahir"`
|
||||
JenisNomerSosial *string `json:"JenisNomerSosial"`
|
||||
NomerSosial *string `json:"NomerSosial"`
|
||||
@@ -186,7 +190,7 @@ func findPossibleMedrecDuplicates(ctx context.Context, tx *sql.Tx, name string,
|
||||
}
|
||||
|
||||
const medrecPatientSelect = `
|
||||
SELECT MEDRECID, Nama, DATE_FORMAT(TanggalLahir, '%Y-%m-%d'),
|
||||
SELECT MEDRECID, Nama, DATE_FORMAT(TanggalLahir, '%Y-%m-%d'), Kelamin,
|
||||
JenisNomerSosial, NomerSosial, Telepon, Ponsel
|
||||
FROM medrec`
|
||||
|
||||
@@ -196,13 +200,14 @@ type rowScanner interface {
|
||||
|
||||
func scanMedrecPatient(row rowScanner) (MedrecPatient, error) {
|
||||
var patient MedrecPatient
|
||||
var name, birthDate, identityType, identityNumber, phone, mobile sql.NullString
|
||||
err := row.Scan(&patient.MEDRECID, &name, &birthDate, &identityType, &identityNumber, &phone, &mobile)
|
||||
var name, birthDate, gender, identityType, identityNumber, phone, mobile sql.NullString
|
||||
err := row.Scan(&patient.MEDRECID, &name, &birthDate, &gender, &identityType, &identityNumber, &phone, &mobile)
|
||||
if err != nil {
|
||||
return MedrecPatient{}, err
|
||||
}
|
||||
patient.Nama = nullStringPtr(name)
|
||||
patient.TanggalLahir = nullStringPtr(birthDate)
|
||||
patient.Kelamin = nullStringPtr(gender)
|
||||
patient.JenisNomerSosial = nullStringPtr(identityType)
|
||||
patient.NomerSosial = nullStringPtr(identityNumber)
|
||||
patient.Telepon = nullStringPtr(phone)
|
||||
@@ -235,10 +240,6 @@ func insertMedrec(ctx context.Context, tx *sql.Tx, medrecID string, params Medre
|
||||
if mobile == "" {
|
||||
mobile = params.Phone
|
||||
}
|
||||
var nik any
|
||||
if params.IdentityType == "KTP" {
|
||||
nik = params.IdentityNumber
|
||||
}
|
||||
firstName := params.Name
|
||||
if index := strings.IndexByte(params.Name, ' '); index >= 0 {
|
||||
firstName = params.Name[:index]
|
||||
@@ -252,14 +253,16 @@ func insertMedrec(ctx context.Context, tx *sql.Tx, medrecID string, params Medre
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO medrec (
|
||||
MEDRECID, KodeRS, Nama, Panggilan, Kelamin, TanggalLahir, TempatLahir,
|
||||
JenisNomerSosial, NomerSosial, NIK, Telepon, Ponsel, Email, Alamat,
|
||||
Propinsi, Kota, Kecamatan, Kelurahan, KodePos, PerusahaanID,
|
||||
JenisNomerSosial, NomerSosial, NIK, DependantID, NamaKaryawan,
|
||||
Telepon, Ponsel, Email, Alamat, Propinsi, Kota, Kecamatan, Kelurahan,
|
||||
KodePos, PerusahaanID,
|
||||
Mati, NA, LoginBuat, TanggalBuat, TanggalEdit
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'N', 'N', ?, ?, ?)`,
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'N', 'N', ?, ?, ?)`,
|
||||
medrecID, params.HospitalCode, params.Name, firstName, params.Gender,
|
||||
params.DateOfBirth.Format("2006-01-02"), params.PlaceOfBirth,
|
||||
params.IdentityType, params.IdentityNumber, nik, phone, mobile, params.Email,
|
||||
params.Address, params.Province, params.City, params.District, params.Village,
|
||||
params.IdentityType, params.IdentityNumber, params.EmployeeID, params.DependantID,
|
||||
params.EmployeeName, phone, mobile, params.Email, params.Address, params.Province,
|
||||
params.City, params.District, params.Village,
|
||||
params.PostalCode, params.CompanyID, params.Channel, now, now)
|
||||
if err != nil {
|
||||
return MedrecPatient{}, err
|
||||
|
||||
@@ -22,6 +22,47 @@ type PaketListParams struct {
|
||||
PerPage int
|
||||
}
|
||||
|
||||
// CreatePaketParams contains values already validated by the HTTP layer.
|
||||
// Monetary values are stored as cents to keep calculations exact without float.
|
||||
type CreatePaketParams struct {
|
||||
Nama string
|
||||
JenisPaket string
|
||||
RawatInap bool
|
||||
RawatJalan bool
|
||||
MultiVisit bool
|
||||
TanggalMulai string
|
||||
TanggalSelesai string
|
||||
KelasID string
|
||||
HeaderPaketID string
|
||||
Keterangan string
|
||||
JumlahHari int64
|
||||
Channel string
|
||||
Layanan []CreatePaketLayananParams
|
||||
}
|
||||
|
||||
type CreatePaketLayananParams struct {
|
||||
LayananID string
|
||||
DepartemenID string
|
||||
Jumlah int64
|
||||
JumlahHari int64
|
||||
HargaCents int64
|
||||
TipeDiskon string
|
||||
DiskonCents int64
|
||||
TipeShare string
|
||||
ShareRSCents int64
|
||||
ShareDokterCents int64
|
||||
ShareExternalCents int64
|
||||
ShareLainCents int64
|
||||
}
|
||||
|
||||
type CreatePaketResult struct {
|
||||
PaketID string
|
||||
Nama string
|
||||
TotalHarga string
|
||||
TotalDiskon string
|
||||
JumlahLayanan int
|
||||
}
|
||||
|
||||
type PaketPage struct {
|
||||
Items []PaketSummary
|
||||
Total int
|
||||
@@ -50,45 +91,58 @@ type PaketSummary struct {
|
||||
}
|
||||
|
||||
type PaketDetail struct {
|
||||
PaketID string `json:"PaketID"`
|
||||
Nama string `json:"Nama"`
|
||||
JenisPaket string `json:"JenisPaket"`
|
||||
RJ int `json:"RJ"`
|
||||
RI int `json:"RI"`
|
||||
TglDari string `json:"TglDari"`
|
||||
TglSampai string `json:"TglSampai"`
|
||||
MultiVisit int `json:"MultiVisit"`
|
||||
HeaderPaket string `json:"HeaderPaket"`
|
||||
KelasID string `json:"KelasID"`
|
||||
JumlahHari int `json:"JumlahHari"`
|
||||
TotalHarga string `json:"TotalHarga"`
|
||||
Keterangan string `json:"Keterangan"`
|
||||
StatusID int `json:"StatusID"`
|
||||
LoginBuat string `json:"LoginBuat"`
|
||||
TanggalBuat string `json:"TanggalBuat"`
|
||||
LoginEdit string `json:"LoginEdit"`
|
||||
TanggalEdit string `json:"TanggalEdit"`
|
||||
NA string `json:"NA"`
|
||||
Layanan []PaketLayanan `json:"layanan"`
|
||||
Operasi []PaketOperasi `json:"operasi"`
|
||||
Item []PaketItem `json:"item"`
|
||||
Kamar []PaketKamar `json:"kamar"`
|
||||
PaketID string `json:"PaketID"`
|
||||
Nama string `json:"Nama"`
|
||||
JenisPaket string `json:"JenisPaket"`
|
||||
RJ int `json:"RJ"`
|
||||
RI int `json:"RI"`
|
||||
TglDari string `json:"TglDari"`
|
||||
TglSampai string `json:"TglSampai"`
|
||||
MultiVisit int `json:"MultiVisit"`
|
||||
HeaderPaket string `json:"HeaderPaket"`
|
||||
KelasID string `json:"KelasID"`
|
||||
JumlahHari int `json:"JumlahHari"`
|
||||
TotalHarga string `json:"TotalHarga"`
|
||||
Keterangan string `json:"Keterangan"`
|
||||
StatusID int `json:"StatusID"`
|
||||
LoginBuat string `json:"LoginBuat"`
|
||||
TanggalBuat string `json:"TanggalBuat"`
|
||||
LoginEdit string `json:"LoginEdit"`
|
||||
TanggalEdit string `json:"TanggalEdit"`
|
||||
NA string `json:"NA"`
|
||||
PeriodeTarif *PaketPeriodeTarif `json:"PeriodeTarif"`
|
||||
Layanan []PaketLayanan `json:"layanan"`
|
||||
Operasi []PaketOperasi `json:"operasi"`
|
||||
Item []PaketItem `json:"item"`
|
||||
Kamar []PaketKamar `json:"kamar"`
|
||||
}
|
||||
|
||||
type PaketPeriodeTarif struct {
|
||||
PeriodeTarifID int64 `json:"PeriodeTarifID"`
|
||||
Keterangan *string `json:"Keterangan"`
|
||||
TanggalBerlaku string `json:"TanggalBerlaku"`
|
||||
TanggalBerakhir string `json:"TanggalBerakhir"`
|
||||
}
|
||||
|
||||
type PaketLayanan struct {
|
||||
PaketLayananID int64 `json:"PaketLayananID"`
|
||||
LayananID string `json:"LayananID"`
|
||||
NamaLayanan *string `json:"NamaLayanan"`
|
||||
NamaDepartemen *string `json:"NamaDepartemen"`
|
||||
Jumlah string `json:"Jumlah"`
|
||||
JumlahHari int `json:"JumlahHari"`
|
||||
Harga string `json:"Harga"`
|
||||
HargaDasar string `json:"HargaDasar"`
|
||||
Diskon string `json:"Diskon"`
|
||||
TipeDiskon string `json:"TipeDiskon"`
|
||||
ShareRS string `json:"ShareRS"`
|
||||
ShareDokter string `json:"ShareDokter"`
|
||||
Subtotal string `json:"Subtotal"`
|
||||
PaketLayananID int64 `json:"PaketLayananID"`
|
||||
LayananID string `json:"LayananID"`
|
||||
DepartemenID string `json:"DepartemenID"`
|
||||
NamaLayanan *string `json:"NamaLayanan"`
|
||||
NamaDepartemen *string `json:"NamaDepartemen"`
|
||||
Jumlah string `json:"Jumlah"`
|
||||
JumlahHari int `json:"JumlahHari"`
|
||||
Harga string `json:"Harga"`
|
||||
HargaDasar string `json:"HargaDasar"`
|
||||
HargaDasarSource string `json:"HargaDasarSource"`
|
||||
Diskon string `json:"Diskon"`
|
||||
TipeDiskon string `json:"TipeDiskon"`
|
||||
TipeShare string `json:"TipeShare"`
|
||||
ShareRS string `json:"ShareRS"`
|
||||
ShareDokter string `json:"ShareDokter"`
|
||||
ShareExternal string `json:"ShareExternal"`
|
||||
ShareLain string `json:"ShareLain"`
|
||||
Subtotal string `json:"Subtotal"`
|
||||
}
|
||||
|
||||
type PaketOperasi struct {
|
||||
@@ -137,8 +191,7 @@ func (r MySQLLayananRepository) ListPaket(ctx context.Context, params PaketListP
|
||||
|
||||
query := `
|
||||
SELECT ph.PaketID,
|
||||
(SELECT pch.KodeCorp FROM paket_corp_header pch
|
||||
WHERE pch.PaketID = ph.PaketID ORDER BY pch.TanggalBuat DESC LIMIT 1),
|
||||
NULL AS KodeCorp,
|
||||
ph.Nama, ph.JenisPaket, jp.Nama, ph.TotalHarga, ph.RI, ph.RJ,
|
||||
ph.MultiVisit, CAST(ph.TglDari AS CHAR), CAST(ph.TglSampai AS CHAR),
|
||||
ph.KelasID, ph.JumlahHari, ph.StatusID, ph.NA, ph.Keterangan
|
||||
@@ -243,12 +296,13 @@ func (r MySQLLayananRepository) GetPaket(ctx context.Context, id string) (*Paket
|
||||
result.TanggalBuat = laravelDateJSON(tanggalBuat)
|
||||
result.TanggalEdit = laravelDateJSON(tanggalEdit)
|
||||
|
||||
tariffTable, err := r.activePaketTariffTable(ctx)
|
||||
periodeTarif, tariffTable, err := r.activePaketTariff(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result.PeriodeTarif = periodeTarif
|
||||
pricing := newPaketBasePricing()
|
||||
if result.Layanan, err = r.paketLayanan(ctx, id, result.KelasID, tariffTable, pricing); err != nil {
|
||||
if result.Layanan, err = r.paketLayanan(ctx, id, result.KelasID, periodeTarif, tariffTable, pricing); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if result.Item, err = r.paketItem(ctx, id, pricing); err != nil {
|
||||
@@ -275,42 +329,51 @@ func newPaketBasePricing() *paketBasePricing {
|
||||
}
|
||||
}
|
||||
|
||||
func (r MySQLLayananRepository) activePaketTariffTable(ctx context.Context) (string, error) {
|
||||
var periodID int64
|
||||
func (r MySQLLayananRepository) activePaketTariff(ctx context.Context) (*PaketPeriodeTarif, string, error) {
|
||||
var period PaketPeriodeTarif
|
||||
var keterangan sql.NullString
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
SELECT PeriodeTarifID
|
||||
SELECT PeriodeTarifID, Keterangan,
|
||||
CAST(TanggalBerlaku AS CHAR), CAST(TanggalBerakhir AS CHAR)
|
||||
FROM periode_tarif
|
||||
WHERE DATE(TanggalBerlaku) <= CURRENT_DATE()
|
||||
AND DATE(TanggalBerakhir) >= CURRENT_DATE()
|
||||
AND NA = 'N'
|
||||
ORDER BY PeriodeTarifID DESC
|
||||
LIMIT 1`).Scan(&periodID)
|
||||
LIMIT 1`).Scan(
|
||||
&period.PeriodeTarifID,
|
||||
&keterangan,
|
||||
&period.TanggalBerlaku,
|
||||
&period.TanggalBerakhir,
|
||||
)
|
||||
if err == sql.ErrNoRows {
|
||||
return "", nil
|
||||
return nil, "", nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
return nil, "", err
|
||||
}
|
||||
period.Keterangan = nullStringPtr(keterangan)
|
||||
|
||||
tableName := "harga_layanan_" + strconv.FormatInt(periodID, 10)
|
||||
tableName := "harga_layanan_" + strconv.FormatInt(period.PeriodeTarifID, 10)
|
||||
exists, err := r.tableExists(ctx, tableName)
|
||||
if err != nil || !exists {
|
||||
return "", err
|
||||
return &period, "", err
|
||||
}
|
||||
return tableName, nil
|
||||
return &period, tableName, nil
|
||||
}
|
||||
|
||||
func (r MySQLLayananRepository) paketLayanan(
|
||||
ctx context.Context,
|
||||
id string,
|
||||
classID string,
|
||||
periodeTarif *PaketPeriodeTarif,
|
||||
tariffTable string,
|
||||
pricing *paketBasePricing,
|
||||
) ([]PaketLayanan, error) {
|
||||
basePriceSelect := "COALESCE(ml.DefaultHarga, 0)"
|
||||
tariffPriceSelect := "NULL"
|
||||
args := make([]any, 0, 3)
|
||||
if tariffTable != "" {
|
||||
basePriceSelect = fmt.Sprintf(`COALESCE((
|
||||
tariffPriceSelect = fmt.Sprintf(`(
|
||||
SELECT tarif.Harga
|
||||
FROM %s tarif
|
||||
INNER JOIN layanandepartemen ld
|
||||
@@ -319,6 +382,7 @@ func (r MySQLLayananRepository) paketLayanan(
|
||||
WHERE ld.LayananID = pl.LayananID
|
||||
AND tarif.KelasID IN (?, 'Default')
|
||||
AND tarif.NA = 'N'
|
||||
AND tarif.Harga > 0
|
||||
ORDER BY CASE
|
||||
WHEN tarif.KelasID = ? AND ld.DepartemenID <=> pl.DepartemenID THEN 1
|
||||
WHEN tarif.KelasID = ? THEN 2
|
||||
@@ -326,53 +390,165 @@ func (r MySQLLayananRepository) paketLayanan(
|
||||
ELSE 4
|
||||
END, tarif.HargaLayananID
|
||||
LIMIT 1
|
||||
), ml.DefaultHarga, 0)`, tariffTable)
|
||||
)`, tariffTable)
|
||||
args = append(args, classID, classID, classID)
|
||||
}
|
||||
args = append(args, id)
|
||||
query := fmt.Sprintf(`
|
||||
SELECT pl.PaketLayananID, pl.LayananID, ml.Nama, d.Nama,
|
||||
SELECT pl.PaketLayananID, pl.LayananID, pl.DepartemenID, ml.Nama, d.Nama,
|
||||
CAST(pl.Jumlah AS CHAR), pl.JumlahHari, CAST(pl.Harga AS CHAR),
|
||||
CAST(pl.Diskon AS CHAR), pl.TipeDiskon, CAST(pl.ShareRS AS CHAR),
|
||||
CAST(pl.ShareDokter AS CHAR), CAST(pl.Subtotal AS CHAR),
|
||||
%s, CAST(pl.HeaderLayanan AS CHAR)
|
||||
CAST(pl.ShareDokter AS CHAR), pl.ShareTipe, CAST(pl.ShareExternal AS CHAR),
|
||||
CAST(pl.ShareLain AS CHAR), CAST(pl.Subtotal AS CHAR),
|
||||
%s, COALESCE(ml.DefaultHarga, 0), COALESCE(ml.LayananKode, ''),
|
||||
CAST(pl.HeaderLayanan AS CHAR)
|
||||
FROM paket_layanan pl
|
||||
LEFT JOIN masterlayanan ml ON ml.LayananID = pl.LayananID
|
||||
LEFT JOIN departemen d ON d.DepartemenID = pl.DepartemenID AND d.NA = 'N'
|
||||
WHERE pl.PaketID = ? AND pl.NA = 'N'
|
||||
ORDER BY pl.PaketLayananID`, basePriceSelect)
|
||||
ORDER BY pl.PaketLayananID`, tariffPriceSelect)
|
||||
rows, err := r.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := make([]PaketLayanan, 0)
|
||||
type servicePricing struct {
|
||||
tariffPrice sql.NullFloat64
|
||||
defaultPrice float64
|
||||
serviceCode string
|
||||
operationHeader sql.NullString
|
||||
}
|
||||
pricingRows := make([]servicePricing, 0)
|
||||
needsAdministrationPrice := false
|
||||
for rows.Next() {
|
||||
var item PaketLayanan
|
||||
var namaLayanan, namaDepartemen sql.NullString
|
||||
var harga, shareRS, shareDokter, subtotal string
|
||||
var basePrice float64
|
||||
var operationHeader sql.NullString
|
||||
var harga, shareRS, shareDokter, tipeShare, shareExternal, shareLain, subtotal string
|
||||
var departemenID string
|
||||
var servicePrice servicePricing
|
||||
if err := rows.Scan(
|
||||
&item.PaketLayananID, &item.LayananID, &namaLayanan, &namaDepartemen,
|
||||
&item.PaketLayananID, &item.LayananID, &departemenID, &namaLayanan, &namaDepartemen,
|
||||
&item.Jumlah, &item.JumlahHari, &harga, &item.Diskon, &item.TipeDiskon,
|
||||
&shareRS, &shareDokter, &subtotal, &basePrice, &operationHeader,
|
||||
&shareRS, &shareDokter, &tipeShare, &shareExternal, &shareLain, &subtotal, &servicePrice.tariffPrice,
|
||||
&servicePrice.defaultPrice, &servicePrice.serviceCode, &servicePrice.operationHeader,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.NamaLayanan = nullStringPtr(namaLayanan)
|
||||
item.NamaDepartemen = nullStringPtr(namaDepartemen)
|
||||
item.DepartemenID = departemenID
|
||||
item.TipeShare = tipeShare
|
||||
item.Harga = phpNumberFormat(parseDecimal(harga), 0)
|
||||
item.HargaDasar = phpNumberFormat(basePrice, 0)
|
||||
item.ShareRS = phpNumberFormat(parseDecimal(shareRS), 2)
|
||||
item.ShareDokter = phpNumberFormat(parseDecimal(shareDokter), 0)
|
||||
item.ShareExternal = phpNumberFormat(parseDecimal(shareExternal), 2)
|
||||
item.ShareLain = phpNumberFormat(parseDecimal(shareLain), 2)
|
||||
item.Subtotal = phpNumberFormat(parseDecimal(subtotal), 0)
|
||||
if operationHeader.Valid {
|
||||
pricing.layananByOperation[operationHeader.String] += basePrice * parseDecimal(item.Jumlah)
|
||||
if servicePrice.tariffPrice.Float64 <= 0 && servicePrice.defaultPrice <= 0 && isAdministrationService(servicePrice.serviceCode) {
|
||||
needsAdministrationPrice = true
|
||||
}
|
||||
result = append(result, item)
|
||||
pricingRows = append(pricingRows, servicePrice)
|
||||
}
|
||||
return result, rows.Err()
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
administrationPrice := 0.0
|
||||
administrationSource := "NOT_AVAILABLE"
|
||||
if needsAdministrationPrice {
|
||||
var err error
|
||||
administrationPrice, administrationSource, err = r.paketAdministrationBasePrice(ctx, periodeTarif)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
for index := range result {
|
||||
servicePrice := pricingRows[index]
|
||||
basePrice, source := paketServiceBasePrice(
|
||||
servicePrice.tariffPrice,
|
||||
servicePrice.defaultPrice,
|
||||
servicePrice.serviceCode,
|
||||
administrationPrice,
|
||||
administrationSource,
|
||||
)
|
||||
result[index].HargaDasar = phpNumberFormat(basePrice, 0)
|
||||
result[index].HargaDasarSource = source
|
||||
if servicePrice.operationHeader.Valid {
|
||||
pricing.layananByOperation[servicePrice.operationHeader.String] += basePrice * parseDecimal(result[index].Jumlah)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func paketServiceBasePrice(
|
||||
tariffPrice sql.NullFloat64,
|
||||
defaultPrice float64,
|
||||
serviceCode string,
|
||||
administrationPrice float64,
|
||||
administrationSource string,
|
||||
) (float64, string) {
|
||||
if tariffPrice.Valid && tariffPrice.Float64 > 0 {
|
||||
return tariffPrice.Float64, "TARIF_PERIODE"
|
||||
}
|
||||
if defaultPrice > 0 {
|
||||
return defaultPrice, "MASTER_DEFAULT"
|
||||
}
|
||||
if isAdministrationService(serviceCode) && administrationPrice > 0 {
|
||||
return administrationPrice, administrationSource
|
||||
}
|
||||
return 0, "NOT_AVAILABLE"
|
||||
}
|
||||
|
||||
func isAdministrationService(serviceCode string) bool {
|
||||
switch strings.ToUpper(strings.TrimSpace(serviceCode)) {
|
||||
case "AN001", "AD001":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (r MySQLLayananRepository) paketAdministrationBasePrice(
|
||||
ctx context.Context,
|
||||
periodeTarif *PaketPeriodeTarif,
|
||||
) (float64, string, error) {
|
||||
if periodeTarif != nil {
|
||||
exists, err := r.tableExists(ctx, "adm_periode")
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
if exists {
|
||||
var price sql.NullFloat64
|
||||
err = r.db.QueryRowContext(ctx, `
|
||||
SELECT NilaiAdministrasi
|
||||
FROM adm_periode
|
||||
WHERE JenisRegID = 0 AND PeriodeTarifID = ?
|
||||
ORDER BY AdmPeriodeID DESC
|
||||
LIMIT 1`, periodeTarif.PeriodeTarifID).Scan(&price)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return 0, "", err
|
||||
}
|
||||
if price.Valid && price.Float64 > 0 {
|
||||
return price.Float64, "ADM_PERIODE", nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var price sql.NullFloat64
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
SELECT NilaiAdministrasi
|
||||
FROM jenisreg
|
||||
WHERE JenisRegID = 0
|
||||
LIMIT 1`).Scan(&price)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return 0, "", err
|
||||
}
|
||||
if price.Valid && price.Float64 > 0 {
|
||||
return price.Float64, "BIAYA_ADMIN", nil
|
||||
}
|
||||
return 0, "NOT_AVAILABLE", nil
|
||||
}
|
||||
|
||||
func (r MySQLLayananRepository) paketOperasi(ctx context.Context, id string, pricing *paketBasePricing) ([]PaketOperasi, error) {
|
||||
|
||||
260
internal/repository/paket_create.go
Normal file
260
internal/repository/paket_create.go
Normal file
@@ -0,0 +1,260 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type paketServiceReference struct {
|
||||
LayananDepartemenID int64
|
||||
Module string
|
||||
}
|
||||
|
||||
func (r MySQLLayananRepository) CreatePaket(ctx context.Context, params CreatePaketParams) (result CreatePaketResult, err error) {
|
||||
conn, err := r.db.Conn(ctx)
|
||||
if err != nil {
|
||||
return CreatePaketResult{}, err
|
||||
}
|
||||
defer conn.Close()
|
||||
tx, err := conn.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return CreatePaketResult{}, err
|
||||
}
|
||||
|
||||
packageID := ""
|
||||
createdDepartments := make([]int64, 0)
|
||||
lockName := ""
|
||||
lockHeld := false
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
_ = tx.Rollback()
|
||||
panic(recovered)
|
||||
}
|
||||
if err != nil {
|
||||
// Some HIS installations still use MyISAM for these tables. Explicit
|
||||
// cleanup makes the operation compensating when ROLLBACK is ineffective.
|
||||
if packageID != "" {
|
||||
_, _ = tx.ExecContext(ctx, `DELETE FROM paket_layanan WHERE PaketID = ?`, packageID)
|
||||
for _, departmentID := range createdDepartments {
|
||||
_, _ = tx.ExecContext(ctx, `DELETE FROM paket_departemen WHERE PaketDepartemenID = ?`, departmentID)
|
||||
}
|
||||
_, _ = tx.ExecContext(ctx, `DELETE FROM paket_header WHERE PaketID = ?`, packageID)
|
||||
}
|
||||
_ = tx.Rollback()
|
||||
}
|
||||
if lockHeld {
|
||||
_, _ = conn.ExecContext(ctx, `SELECT RELEASE_LOCK(?)`, lockName)
|
||||
}
|
||||
}()
|
||||
|
||||
nowLocation, locationErr := time.LoadLocation("Asia/Jakarta")
|
||||
if locationErr != nil {
|
||||
return CreatePaketResult{}, locationErr
|
||||
}
|
||||
nowTime := time.Now().In(nowLocation)
|
||||
now := nowTime.Format("2006-01-02 15:04:05")
|
||||
prefix := "DISP-" + nowTime.Format("0601")
|
||||
lockName = "cpone:create-paket:" + prefix
|
||||
var lockResult int
|
||||
if err = conn.QueryRowContext(ctx, `SELECT GET_LOCK(?, 10)`, lockName).Scan(&lockResult); err != nil {
|
||||
return CreatePaketResult{}, err
|
||||
}
|
||||
if lockResult != 1 {
|
||||
return CreatePaketResult{}, ValidationError{Message: "Pembuatan paket sedang diproses, silakan coba lagi"}
|
||||
}
|
||||
lockHeld = true
|
||||
|
||||
if err = validatePaketReferences(ctx, tx, params); err != nil {
|
||||
return CreatePaketResult{}, err
|
||||
}
|
||||
var duplicate int
|
||||
err = tx.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM paket_header WHERE LOWER(TRIM(Nama)) = LOWER(TRIM(?)) AND NA = 'N')`, params.Nama).Scan(&duplicate)
|
||||
if err != nil {
|
||||
return CreatePaketResult{}, err
|
||||
}
|
||||
if duplicate != 0 {
|
||||
return CreatePaketResult{}, ValidationError{Message: "Nama paket sudah digunakan"}
|
||||
}
|
||||
|
||||
packageID, err = nextPaketID(ctx, tx, prefix)
|
||||
if err != nil {
|
||||
return CreatePaketResult{}, err
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO paket_header (
|
||||
PaketID, Nama, JenisPaket, RJ, RI, MultiVisit, TglDari, TglSampai,
|
||||
HeaderPaket, KelasID, JumlahHari, TotalHarga, Keterangan, StatusID,
|
||||
LoginBuat, TanggalBuat, NA
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 2, ?, ?, 'N')`,
|
||||
packageID, params.Nama, params.JenisPaket, boolInt(params.RawatJalan), boolInt(params.RawatInap),
|
||||
boolInt(params.MultiVisit), nullableDate(params.TanggalMulai), nullableDate(params.TanggalSelesai),
|
||||
params.HeaderPaketID, params.KelasID, params.JumlahHari, "0.00", params.Keterangan, params.Channel, now)
|
||||
if err != nil {
|
||||
return CreatePaketResult{}, err
|
||||
}
|
||||
|
||||
totalPrice := int64(0)
|
||||
totalDiscount := int64(0)
|
||||
for _, service := range params.Layanan {
|
||||
departmentID, created, serviceErr := ensurePaketDepartment(ctx, tx, packageID, service.DepartemenID)
|
||||
if serviceErr != nil {
|
||||
return CreatePaketResult{}, serviceErr
|
||||
}
|
||||
if created {
|
||||
createdDepartments = append(createdDepartments, departmentID)
|
||||
}
|
||||
discountAmount := service.DiskonCents
|
||||
if service.TipeDiskon == "Percentage" {
|
||||
discountAmount = service.HargaCents * service.DiskonCents / 10000
|
||||
}
|
||||
subtotal := (service.HargaCents - discountAmount) * service.Jumlah
|
||||
totalPrice += subtotal
|
||||
totalDiscount += discountAmount * service.Jumlah
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO paket_layanan (
|
||||
PaketID, PaketDepartemenID, DepartemenID, LayananID, HeaderLayanan,
|
||||
Jumlah, JumlahHari, Harga, TipeDiskon, Diskon, ShareTipe, ShareRS,
|
||||
ShareDokter, ShareExternal, ShareLain, Subtotal, LoginBuat, TanggalBuat, NA
|
||||
) VALUES (?, ?, ?, ?, '', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'N')`,
|
||||
packageID, departmentID, service.DepartemenID, service.LayananID, service.Jumlah, service.JumlahHari,
|
||||
decimalText(service.HargaCents), service.TipeDiskon, decimalText(service.DiskonCents), service.TipeShare,
|
||||
decimalText(service.ShareRSCents), decimalText(service.ShareDokterCents), decimalText(service.ShareExternalCents),
|
||||
decimalText(service.ShareLainCents), decimalText(subtotal), params.Channel, now)
|
||||
if err != nil {
|
||||
return CreatePaketResult{}, err
|
||||
}
|
||||
}
|
||||
|
||||
_, err = tx.ExecContext(ctx, `UPDATE paket_header SET TotalHarga = ? WHERE PaketID = ?`, decimalText(totalPrice), packageID)
|
||||
if err != nil {
|
||||
return CreatePaketResult{}, err
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return CreatePaketResult{}, err
|
||||
}
|
||||
if lockHeld {
|
||||
_, _ = conn.ExecContext(ctx, `SELECT RELEASE_LOCK(?)`, lockName)
|
||||
lockHeld = false
|
||||
}
|
||||
return CreatePaketResult{
|
||||
PaketID: packageID, Nama: params.Nama, TotalHarga: decimalText(totalPrice),
|
||||
TotalDiskon: decimalText(totalDiscount), JumlahLayanan: len(params.Layanan),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func validatePaketReferences(ctx context.Context, tx *sql.Tx, params CreatePaketParams) error {
|
||||
var found int
|
||||
if err := tx.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM jenispaket WHERE JenisPaketID = ?)`, params.JenisPaket).Scan(&found); err != nil {
|
||||
return err
|
||||
}
|
||||
if found == 0 {
|
||||
return ValidationError{Message: "Jenis paket tidak ditemukan"}
|
||||
}
|
||||
if err := tx.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM kelas WHERE KelasID = ? AND NA = 'N')`, params.KelasID).Scan(&found); err != nil {
|
||||
return err
|
||||
}
|
||||
if found == 0 {
|
||||
return ValidationError{Message: "Kelas paket tidak ditemukan atau tidak aktif"}
|
||||
}
|
||||
for _, service := range params.Layanan {
|
||||
ref, err := findPaketServiceReference(ctx, tx, service.LayananID, service.DepartemenID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
packageType := strings.ToUpper(strings.TrimSpace(params.JenisPaket))
|
||||
module := strings.ToUpper(ref.Module)
|
||||
if packageType == "LAB" && !strings.Contains(module, "LAB") {
|
||||
return ValidationError{Message: fmt.Sprintf("Departemen %s bukan departemen laboratorium", service.DepartemenID)}
|
||||
}
|
||||
if packageType == "RAD" && !strings.Contains(module, "RAD") {
|
||||
return ValidationError{Message: fmt.Sprintf("Departemen %s bukan departemen radiologi", service.DepartemenID)}
|
||||
}
|
||||
if (packageType == "LAB" || packageType == "RAD") && service.JumlahHari > 1 {
|
||||
return ValidationError{Message: "Jumlah hari layanan paket LAB/RAD maksimal 1"}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func findPaketServiceReference(ctx context.Context, tx *sql.Tx, serviceID, departmentID string) (paketServiceReference, error) {
|
||||
var ref paketServiceReference
|
||||
err := tx.QueryRowContext(ctx, `
|
||||
SELECT 0, d.NamaModulExternal
|
||||
FROM masterlayanan ml
|
||||
INNER JOIN departemen d ON d.DepartemenID = ?
|
||||
WHERE ml.LayananID = ?
|
||||
LIMIT 1`, departmentID, serviceID).Scan(&ref.LayananDepartemenID, &ref.Module)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return paketServiceReference{}, ValidationError{Message: fmt.Sprintf("Layanan %s atau departemen %s tidak ditemukan", serviceID, departmentID)}
|
||||
}
|
||||
if err != nil {
|
||||
return paketServiceReference{}, err
|
||||
}
|
||||
return ref, nil
|
||||
}
|
||||
|
||||
func ensurePaketDepartment(ctx context.Context, tx *sql.Tx, packageID, departmentID string) (int64, bool, error) {
|
||||
var id int64
|
||||
err := tx.QueryRowContext(ctx, `
|
||||
SELECT PaketDepartemenID FROM paket_departemen
|
||||
WHERE PaketID = ? AND DepartemenID = ? AND NA = 'N'
|
||||
ORDER BY PaketDepartemenID LIMIT 1 FOR UPDATE`, packageID, departmentID).Scan(&id)
|
||||
if err == nil {
|
||||
return id, false, nil
|
||||
}
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, false, err
|
||||
}
|
||||
result, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO paket_departemen (PaketID, DepartemenID, NA)
|
||||
VALUES (?, ?, 'N')`, packageID, departmentID)
|
||||
if err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
id, err = result.LastInsertId()
|
||||
if err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
return id, true, nil
|
||||
}
|
||||
|
||||
func nextPaketID(ctx context.Context, tx *sql.Tx, prefix string) (string, error) {
|
||||
var lastID sql.NullString
|
||||
err := tx.QueryRowContext(ctx, `
|
||||
SELECT PaketID FROM paket_header
|
||||
WHERE PaketID LIKE ? ORDER BY PaketID DESC LIMIT 1 FOR UPDATE`, prefix+"%").Scan(&lastID)
|
||||
if errors.Is(err, sql.ErrNoRows) || !lastID.Valid {
|
||||
return prefix + "00001", nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sequence, err := strconv.Atoi(strings.TrimPrefix(lastID.String, prefix))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("format PaketID terakhir tidak valid: %w", err)
|
||||
}
|
||||
return fmt.Sprintf("%s%05d", prefix, sequence+1), nil
|
||||
}
|
||||
|
||||
func boolInt(value bool) int {
|
||||
if value {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func nullableDate(value string) any {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return nil
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func decimalText(cents int64) string {
|
||||
return fmt.Sprintf("%d.%02d", cents/100, cents%100)
|
||||
}
|
||||
18
internal/repository/paket_create_test.go
Normal file
18
internal/repository/paket_create_test.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package repository
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDecimalText(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
cents int64
|
||||
want string
|
||||
}{
|
||||
{cents: 13000000, want: "130000.00"},
|
||||
{cents: 1, want: "0.01"},
|
||||
{cents: 0, want: "0.00"},
|
||||
} {
|
||||
if got := decimalText(test.cents); got != test.want {
|
||||
t.Fatalf("decimalText(%d)=%q want=%q", test.cents, got, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -94,3 +94,62 @@ func TestPaketItemBasePriceFallbackParity(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaketServiceBasePriceFallbackParity(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
tariffPrice sql.NullFloat64
|
||||
defaultPrice float64
|
||||
serviceCode string
|
||||
administrationPrice float64
|
||||
administrationSource string
|
||||
expectedPrice float64
|
||||
expectedSource string
|
||||
}{
|
||||
{
|
||||
name: "positive period tariff has highest priority",
|
||||
tariffPrice: sql.NullFloat64{Float64: 150000, Valid: true},
|
||||
defaultPrice: 100000, serviceCode: "LAB001",
|
||||
expectedPrice: 150000, expectedSource: "TARIF_PERIODE",
|
||||
},
|
||||
{
|
||||
name: "zero period tariff does not override master default",
|
||||
tariffPrice: sql.NullFloat64{Float64: 0, Valid: true},
|
||||
defaultPrice: 100000, serviceCode: "LAB001",
|
||||
expectedPrice: 100000, expectedSource: "MASTER_DEFAULT",
|
||||
},
|
||||
{
|
||||
name: "AN001 uses administration period fallback",
|
||||
serviceCode: "an001", administrationPrice: 75000,
|
||||
administrationSource: "ADM_PERIODE",
|
||||
expectedPrice: 75000, expectedSource: "ADM_PERIODE",
|
||||
},
|
||||
{
|
||||
name: "AD001 uses registration master fallback",
|
||||
serviceCode: " AD001 ", administrationPrice: 50000,
|
||||
administrationSource: "BIAYA_ADMIN",
|
||||
expectedPrice: 50000, expectedSource: "BIAYA_ADMIN",
|
||||
},
|
||||
{
|
||||
name: "non administration service does not use administration fallback",
|
||||
serviceCode: "LAB001", administrationPrice: 50000,
|
||||
administrationSource: "BIAYA_ADMIN",
|
||||
expectedPrice: 0, expectedSource: "NOT_AVAILABLE",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
price, source := paketServiceBasePrice(
|
||||
test.tariffPrice,
|
||||
test.defaultPrice,
|
||||
test.serviceCode,
|
||||
test.administrationPrice,
|
||||
test.administrationSource,
|
||||
)
|
||||
if price != test.expectedPrice || source != test.expectedSource {
|
||||
t.Fatalf("price=%v source=%q want price=%v source=%q", price, source, test.expectedPrice, test.expectedSource)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
176
internal/repository/periode_tarif.go
Normal file
176
internal/repository/periode_tarif.go
Normal file
@@ -0,0 +1,176 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type PeriodeTarif struct {
|
||||
PeriodeTarifID int64 `json:"PeriodeTarifID"`
|
||||
Keterangan *string `json:"Keterangan"`
|
||||
TanggalBerlaku string `json:"TanggalBerlaku"`
|
||||
TanggalBerakhir string `json:"TanggalBerakhir"`
|
||||
}
|
||||
|
||||
func (r MySQLLayananRepository) ListPeriodeTarif(ctx context.Context) ([]PeriodeTarif, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT PeriodeTarifID, Keterangan,
|
||||
CAST(TanggalBerlaku AS CHAR), CAST(TanggalBerakhir AS CHAR)
|
||||
FROM periode_tarif
|
||||
WHERE NA = 'N'
|
||||
ORDER BY Keterangan ASC, PeriodeTarifID ASC
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
result := make([]PeriodeTarif, 0)
|
||||
for rows.Next() {
|
||||
var item PeriodeTarif
|
||||
var keterangan sql.NullString
|
||||
if err := rows.Scan(&item.PeriodeTarifID, &keterangan,
|
||||
&item.TanggalBerlaku, &item.TanggalBerakhir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.Keterangan = nullStringPtr(keterangan)
|
||||
result = append(result, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type PeriodeTarifLayanan struct {
|
||||
TarifLayananID int64 `json:"TarifLayananID"`
|
||||
LayananID *string `json:"LayananID"`
|
||||
NamaLayanan *string `json:"NamaLayanan"`
|
||||
LayananDepartemenID int64 `json:"LayananDepartemenID"`
|
||||
Departemen *string `json:"Departemen"`
|
||||
Kelas string `json:"Kelas"`
|
||||
NamaKelas *string `json:"NamaKelas"`
|
||||
Harga *string `json:"Harga"`
|
||||
TipeShare *string `json:"TipeShare"`
|
||||
ShareDokter *string `json:"ShareDokter"`
|
||||
ShareRS *string `json:"ShareRS"`
|
||||
ShareExternal *string `json:"ShareExternal"`
|
||||
ShareLain *string `json:"ShareLain"`
|
||||
}
|
||||
|
||||
type PeriodeTarifLayananListParams struct {
|
||||
PeriodeTarifID int64
|
||||
Page int
|
||||
PerPage int
|
||||
Search string
|
||||
}
|
||||
|
||||
type PeriodeTarifLayananPage struct {
|
||||
Items []PeriodeTarifLayanan
|
||||
Total int
|
||||
}
|
||||
|
||||
func (r MySQLLayananRepository) ListPeriodeTarifLayanan(ctx context.Context, params PeriodeTarifLayananListParams) (PeriodeTarifLayananPage, error) {
|
||||
tableName := "harga_layanan_" + strconv.FormatInt(params.PeriodeTarifID, 10)
|
||||
exists, err := r.tableExists(ctx, tableName)
|
||||
if err != nil {
|
||||
return PeriodeTarifLayananPage{}, err
|
||||
}
|
||||
if !exists {
|
||||
return PeriodeTarifLayananPage{}, ErrTarifTableNotFound
|
||||
}
|
||||
|
||||
where := `
|
||||
WHERE tarif.NA = 'N'
|
||||
AND layanan_dept.NA = 'N'`
|
||||
args := make([]any, 0, 4)
|
||||
if params.Search != "" {
|
||||
where += `
|
||||
AND (layanan_dept.LayananID LIKE ? OR layanan.Nama LIKE ?)`
|
||||
like := "%" + params.Search + "%"
|
||||
args = append(args, like, like)
|
||||
}
|
||||
|
||||
countQuery := fmt.Sprintf(`
|
||||
SELECT COUNT(*)
|
||||
FROM %s AS tarif
|
||||
INNER JOIN layanandepartemen AS layanan_dept
|
||||
ON layanan_dept.LayananDepartemenID = tarif.LayananDepartemenID
|
||||
LEFT JOIN masterlayanan AS layanan
|
||||
ON layanan.LayananID = layanan_dept.LayananID
|
||||
%s
|
||||
`, tableName, where)
|
||||
var total int
|
||||
if err := r.db.QueryRowContext(ctx, countQuery, args...).Scan(&total); err != nil {
|
||||
return PeriodeTarifLayananPage{}, err
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
SELECT
|
||||
tarif.HargaLayananID,
|
||||
layanan_dept.LayananID,
|
||||
layanan.Nama,
|
||||
tarif.LayananDepartemenID,
|
||||
departemen.Nama,
|
||||
tarif.KelasID,
|
||||
kelas.Nama,
|
||||
CAST(tarif.Harga AS CHAR),
|
||||
tarif.ShareTipe,
|
||||
CAST(tarif.ShareDokter AS CHAR),
|
||||
CAST(tarif.ShareRS AS CHAR),
|
||||
CAST(tarif.ShareExternal AS CHAR),
|
||||
CAST(tarif.ShareLain AS CHAR)
|
||||
FROM %s AS tarif
|
||||
INNER JOIN layanandepartemen AS layanan_dept
|
||||
ON layanan_dept.LayananDepartemenID = tarif.LayananDepartemenID
|
||||
LEFT JOIN masterlayanan AS layanan
|
||||
ON layanan.LayananID = layanan_dept.LayananID
|
||||
LEFT JOIN departemen AS departemen
|
||||
ON departemen.DepartemenID = layanan_dept.DepartemenID
|
||||
LEFT JOIN kelas AS kelas
|
||||
ON kelas.KelasID = tarif.KelasID
|
||||
%s
|
||||
ORDER BY layanan.Nama, layanan_dept.LayananID, tarif.KelasID, tarif.HargaLayananID
|
||||
LIMIT ? OFFSET ?
|
||||
`, tableName, where)
|
||||
args = append(args, params.PerPage, (params.Page-1)*params.PerPage)
|
||||
|
||||
rows, err := r.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return PeriodeTarifLayananPage{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
result := make([]PeriodeTarifLayanan, 0)
|
||||
for rows.Next() {
|
||||
var item PeriodeTarifLayanan
|
||||
var layananID, namaLayanan, departemen, namaKelas, harga sql.NullString
|
||||
var tipeShare, shareDokter, shareRS, shareExternal, shareLain sql.NullString
|
||||
if err := rows.Scan(
|
||||
&item.TarifLayananID, &layananID, &namaLayanan,
|
||||
&item.LayananDepartemenID, &departemen, &item.Kelas, &namaKelas,
|
||||
&harga, &tipeShare, &shareDokter, &shareRS, &shareExternal, &shareLain,
|
||||
); err != nil {
|
||||
return PeriodeTarifLayananPage{}, err
|
||||
}
|
||||
item.LayananID = nullStringPtr(layananID)
|
||||
item.NamaLayanan = nullStringPtr(namaLayanan)
|
||||
item.Departemen = nullStringPtr(departemen)
|
||||
item.NamaKelas = nullStringPtr(namaKelas)
|
||||
item.Harga = nullStringPtr(harga)
|
||||
item.TipeShare = nullStringPtr(tipeShare)
|
||||
item.ShareDokter = nullStringPtr(shareDokter)
|
||||
item.ShareRS = nullStringPtr(shareRS)
|
||||
item.ShareExternal = nullStringPtr(shareExternal)
|
||||
item.ShareLain = nullStringPtr(shareLain)
|
||||
result = append(result, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return PeriodeTarifLayananPage{}, err
|
||||
}
|
||||
|
||||
return PeriodeTarifLayananPage{Items: result, 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 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.Keterangan"
|
||||
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),
|
||||
'' AS TipeDiskonRI, '0' AS DiskonRI, 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
|
||||
}
|
||||
511
internal/repository/registration.go
Normal file
511
internal/repository/registration.go
Normal file
@@ -0,0 +1,511 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type RegistrationParams struct {
|
||||
MedrecID string
|
||||
NIK string
|
||||
BirthDate time.Time
|
||||
RegistrationDate time.Time
|
||||
DepartmentID string
|
||||
DoctorID string
|
||||
PatientTypeID int64
|
||||
RegistrationType int64
|
||||
TariffPeriodID int64
|
||||
CompanyID string
|
||||
InsuranceID string
|
||||
GroupTariffID int64
|
||||
ProviderID string
|
||||
PolicyNumber string
|
||||
GuaranteeNumber string
|
||||
EntryCode string
|
||||
ClassID string
|
||||
Notes string
|
||||
Referrer string
|
||||
EmployeeID string
|
||||
EmployeeName string
|
||||
Channel string
|
||||
PackageID string
|
||||
}
|
||||
|
||||
type RegistrationOutcome struct {
|
||||
HTTPStatus int
|
||||
Success bool
|
||||
Status string
|
||||
Message string
|
||||
Data any
|
||||
}
|
||||
|
||||
type RegistrationData struct {
|
||||
Patient MedrecPatient `json:"patient"`
|
||||
Registration RegistrationRecord `json:"registration"`
|
||||
Package *RegistrationPackage `json:"package,omitempty"`
|
||||
}
|
||||
|
||||
type RegistrationPackage struct {
|
||||
PaketDispenserID int64 `json:"PaketDispenserID"`
|
||||
PaketID string `json:"PaketID"`
|
||||
Nama string `json:"Nama"`
|
||||
KunjunganKe int64 `json:"KunjunganKe"`
|
||||
}
|
||||
|
||||
type RegistrationRecord struct {
|
||||
RegID string `json:"RegID"`
|
||||
MEDRECID string `json:"MEDRECID"`
|
||||
Tanggal string `json:"Tanggal"`
|
||||
DepartmentID string `json:"DepartemenID"`
|
||||
DoctorID string `json:"DokterID"`
|
||||
PatientTypeID int64 `json:"JenisPasienID"`
|
||||
RegistrationID int64 `json:"JenisRegID"`
|
||||
TariffPeriodID int64 `json:"PeriodeTarifID"`
|
||||
CompanyID string `json:"PerusahaanID"`
|
||||
InsuranceID string `json:"AsuransiID"`
|
||||
ProviderID string `json:"ProviderID"`
|
||||
GroupTariffID int64 `json:"GroupTarifID"`
|
||||
PolicyNumber string `json:"NomerPolis"`
|
||||
Pulang string `json:"Pulang"`
|
||||
BatalDaftar string `json:"BatalDaftar"`
|
||||
}
|
||||
|
||||
func (r MySQLLayananRepository) RegisterPatient(ctx context.Context, params RegistrationParams) (RegistrationOutcome, error) {
|
||||
for attempt := 0; attempt < 3; attempt++ {
|
||||
outcome, err := r.registerPatientAttempt(ctx, params)
|
||||
if err == nil {
|
||||
return outcome, nil
|
||||
}
|
||||
if !isConcurrencyError(err) || ctx.Err() != nil {
|
||||
return RegistrationOutcome{}, err
|
||||
}
|
||||
}
|
||||
return RegistrationOutcome{}, fmt.Errorf("registrasi pasien gagal karena konflik transaksi")
|
||||
}
|
||||
|
||||
func (r MySQLLayananRepository) registerPatientAttempt(ctx context.Context, params RegistrationParams) (outcome RegistrationOutcome, err error) {
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return RegistrationOutcome{}, err
|
||||
}
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
_ = tx.Rollback()
|
||||
panic(recovered)
|
||||
}
|
||||
if err != nil {
|
||||
_ = tx.Rollback()
|
||||
}
|
||||
}()
|
||||
|
||||
outcome, err = registerPatientInTransaction(ctx, tx, params)
|
||||
if err != nil {
|
||||
return RegistrationOutcome{}, err
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return RegistrationOutcome{}, err
|
||||
}
|
||||
return outcome, nil
|
||||
}
|
||||
|
||||
func registerPatientInTransaction(ctx context.Context, tx *sql.Tx, params RegistrationParams) (RegistrationOutcome, error) {
|
||||
patient, found, err := findRegistrationPatient(ctx, tx, params)
|
||||
if err != nil {
|
||||
return RegistrationOutcome{}, err
|
||||
}
|
||||
if !found {
|
||||
return registrationOutcome(404, false, "patient_not_found", "Pasien tidak ditemukan", nil), nil
|
||||
}
|
||||
|
||||
existing, found, err := findExistingRegistration(ctx, tx, patient.MEDRECID, params)
|
||||
if err != nil {
|
||||
return RegistrationOutcome{}, err
|
||||
}
|
||||
if found {
|
||||
data := RegistrationData{Patient: patient, Registration: existing}
|
||||
if strings.TrimSpace(params.PackageID) != "" {
|
||||
data.Package, err = addRegistrationPackage(ctx, tx, existing, patient.MEDRECID, params.PackageID, params.Channel, params.RegistrationDate)
|
||||
if err != nil {
|
||||
return RegistrationOutcome{}, err
|
||||
}
|
||||
}
|
||||
return registrationOutcome(200, true, "already_registered_visit", "Pasien sudah memiliki registrasi aktif pada tujuan yang sama", data), nil
|
||||
}
|
||||
|
||||
jakarta, err := time.LoadLocation("Asia/Jakarta")
|
||||
if err != nil {
|
||||
return RegistrationOutcome{}, err
|
||||
}
|
||||
now := time.Now().In(jakarta)
|
||||
nowText := now.Format("2006-01-02 15:04:05")
|
||||
regDate := params.RegistrationDate.Format("2006-01-02")
|
||||
prefix := registrationIDPrefix(params.RegistrationType, params.RegistrationDate)
|
||||
regID, err := nextRegistrationID(ctx, tx, prefix)
|
||||
if err != nil {
|
||||
return RegistrationOutcome{}, err
|
||||
}
|
||||
shiftID, err := resolveDailyShiftID(ctx, tx, nil, params.RegistrationDate)
|
||||
if err != nil {
|
||||
return RegistrationOutcome{}, err
|
||||
}
|
||||
|
||||
var previousVisits int64
|
||||
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM regpas WHERE MEDRECID = ? AND NA = 'N'`, patient.MEDRECID).Scan(&previousVisits); err != nil {
|
||||
return RegistrationOutcome{}, err
|
||||
}
|
||||
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO regpas SET
|
||||
RegID = ?, MEDRECID = ?, Nama = ?, ALAMATEXTPAS = ?, TELPEXTPAS = ?, TglLahir = ?, JnsKelamin = ?,
|
||||
KunjunganKe = ?, Tanggal = ?, JenisPasienID = ?, PeriodeTarifID = ?, PerusahaanID = ?,
|
||||
GroupTarifID = ?, AsuransiID = ?, ProviderID = ?, NomerPolis = ?, NIK = ?, NamaKaryawan = ?,
|
||||
NomerJaminan = ?, KodeMasukID = ?, DepartemenID = ?, DepartemenID2 = ?, JenisRegID = ?,
|
||||
Perujuk = ?, DokterID = ?, DokterID2 = '', KelasID = ?, MataUang = 'IDR', Rate = 1,
|
||||
Pulang = 'N', BolehPulang = 'N', Lunas = 'N', BatalDaftar = 'N', Catatan = '',
|
||||
CatatanMasuk = ?, ShiftHarianIDDaftar = ?, LoginBuat = ?, TanggalBuat = ?, NA = 'N'`,
|
||||
regID,
|
||||
patient.MEDRECID,
|
||||
patient.Nama,
|
||||
"",
|
||||
firstNonEmpty(pointerValue(patient.Ponsel), pointerValue(patient.Telepon)),
|
||||
pointerValue(patient.TanggalLahir),
|
||||
firstNonEmpty(pointerValue(patient.Kelamin), "L"),
|
||||
previousVisits+1,
|
||||
regDate,
|
||||
params.PatientTypeID,
|
||||
params.TariffPeriodID,
|
||||
params.CompanyID,
|
||||
params.GroupTariffID,
|
||||
params.InsuranceID,
|
||||
params.ProviderID,
|
||||
params.PolicyNumber,
|
||||
firstNonEmpty(params.EmployeeID, params.NIK, pointerValue(patient.NomerSosial)),
|
||||
params.EmployeeName,
|
||||
params.GuaranteeNumber,
|
||||
params.EntryCode,
|
||||
params.DepartmentID,
|
||||
params.DepartmentID,
|
||||
params.RegistrationType,
|
||||
params.Referrer,
|
||||
params.DoctorID,
|
||||
params.ClassID,
|
||||
params.Notes,
|
||||
shiftID,
|
||||
params.Channel,
|
||||
nowText,
|
||||
)
|
||||
if err != nil {
|
||||
return RegistrationOutcome{}, err
|
||||
}
|
||||
|
||||
registration := RegistrationRecord{
|
||||
RegID: regID, MEDRECID: patient.MEDRECID, Tanggal: regDate,
|
||||
DepartmentID: params.DepartmentID, DoctorID: params.DoctorID,
|
||||
PatientTypeID: params.PatientTypeID, RegistrationID: params.RegistrationType,
|
||||
TariffPeriodID: params.TariffPeriodID, CompanyID: params.CompanyID,
|
||||
InsuranceID: params.InsuranceID, ProviderID: params.ProviderID,
|
||||
GroupTariffID: params.GroupTariffID, PolicyNumber: params.PolicyNumber,
|
||||
Pulang: "N", BatalDaftar: "N",
|
||||
}
|
||||
data := RegistrationData{Patient: patient, Registration: registration}
|
||||
if strings.TrimSpace(params.PackageID) != "" {
|
||||
data.Package, err = addRegistrationPackage(ctx, tx, registration, patient.MEDRECID, params.PackageID, params.Channel, params.RegistrationDate)
|
||||
if err != nil {
|
||||
return RegistrationOutcome{}, err
|
||||
}
|
||||
}
|
||||
return registrationOutcome(201, true, "registered", "Registrasi berhasil disimpan", data), nil
|
||||
}
|
||||
|
||||
func addRegistrationPackage(ctx context.Context, tx *sql.Tx, registration RegistrationRecord, medrecID, packageID, channel string, registrationDate time.Time) (*RegistrationPackage, error) {
|
||||
packageID = strings.ToUpper(strings.TrimSpace(packageID))
|
||||
header, found, err := findActiveOutpatientPackage(ctx, tx, packageID, registrationDate)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !found {
|
||||
return nil, ValidationError{Message: "Paket dispenser MCU tidak ditemukan, belum disetujui, atau sudah tidak berlaku"}
|
||||
}
|
||||
|
||||
jakarta, err := time.LoadLocation("Asia/Jakarta")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := time.Now().In(jakarta).Format("2006-01-02 15:04:05")
|
||||
if registration.RegistrationID == 2 {
|
||||
if _, err := ensureMCUTransaction(ctx, tx, registration, channel, now); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
dbRegistration := dbLabRegistration{RegID: registration.RegID, MEDRECID: medrecID}
|
||||
dispenserID, visitNumber, err := ensurePackageDispenser(ctx, tx, dbRegistration, header, channel, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if registration.RegistrationID == 2 {
|
||||
if err := ensureMCUPackageDetails(ctx, tx, dispenserID, registration.RegID, medrecID, header.ID, channel, now); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return &RegistrationPackage{
|
||||
PaketDispenserID: dispenserID,
|
||||
PaketID: header.ID,
|
||||
Nama: header.Name,
|
||||
KunjunganKe: visitNumber,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func ensureMCUTransaction(ctx context.Context, tx *sql.Tx, registration RegistrationRecord, channel, now string) (int64, error) {
|
||||
var transactionID int64
|
||||
err := tx.QueryRowContext(ctx, `
|
||||
SELECT TrxDepartemenID
|
||||
FROM trxdepartemen
|
||||
WHERE RegID = ? AND DepartemenID = ? AND DokterID = ? AND NA = 'N'
|
||||
ORDER BY TrxDepartemenID DESC LIMIT 1 FOR UPDATE`, registration.RegID, registration.DepartmentID, registration.DoctorID).Scan(&transactionID)
|
||||
if err == nil {
|
||||
return transactionID, nil
|
||||
}
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
result, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO trxdepartemen (
|
||||
RegID, TrxID, DepartemenID, DokterID, Dokter2, AntrianID, Tanggal,
|
||||
KelasID, MataUang, StatusID, LogID, LoginBuat, TanggalBuat, NA
|
||||
) VALUES (?, ?, ?, ?, '', 0, ?, ?, 'IDR', 0, 0, ?, ?, 'N')`,
|
||||
registration.RegID, registration.RegID, registration.DepartmentID, registration.DoctorID,
|
||||
now, "Default", channel, now,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.LastInsertId()
|
||||
}
|
||||
|
||||
type registrationPackageItem struct {
|
||||
PackageDepartmentID sql.NullInt64
|
||||
ItemDepartmentID sql.NullInt64
|
||||
ItemWarehouseID sql.NullInt64
|
||||
ItemID sql.NullString
|
||||
HeaderID sql.NullInt64
|
||||
DepartmentID sql.NullString
|
||||
UnitID sql.NullString
|
||||
BaseUnitID sql.NullString
|
||||
Quantity sql.NullString
|
||||
Price sql.NullString
|
||||
DiscountType sql.NullString
|
||||
Discount sql.NullString
|
||||
}
|
||||
|
||||
func ensureMCUPackageDetails(ctx context.Context, tx *sql.Tx, dispenserID int64, regID, medrecID, packageID, channel, now string) error {
|
||||
services, err := findActivePackageServices(ctx, tx, packageID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, service := range services {
|
||||
var present int
|
||||
err := tx.QueryRowContext(ctx, `
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM paket_dispenser_layanan
|
||||
WHERE PaketDispenserID = ? AND LayananID = ? AND NA = 'N'
|
||||
)`, dispenserID, service.ServiceID).Scan(&present)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if present != 0 {
|
||||
continue
|
||||
}
|
||||
operationID, err := resolvePackageOperationIDForMCU(ctx, tx, service.HeaderService)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO paket_dispenser_layanan (
|
||||
PaketDispenserID, PaketDispenserOperasiID, RegID, MEDRECID, PaketID,
|
||||
DepartemenID, LayananID, HeaderLayanan, JumlahAwal, Jumlah, JumlahHari,
|
||||
Harga, TipeDiskon, Diskon, ShareTipe, ShareRS, ShareDokter, ShareExternal,
|
||||
ShareLain, LoginBuat, TanggalBuat, NA
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'N')`,
|
||||
dispenserID, operationID, regID, medrecID, packageID, service.DepartmentID, service.ServiceID,
|
||||
nullStringValue(service.HeaderService), nullStringValue(service.Quantity), nullStringValue(service.Quantity),
|
||||
nullInt64Value(service.Days), nullStringValue(service.Price), nullStringValue(service.DiscountType),
|
||||
nullStringValue(service.Discount), nullStringValue(service.ShareType), nullStringValue(service.ShareHospital),
|
||||
nullStringValue(service.ShareDoctor), nullStringValue(service.ShareExternal), nullStringValue(service.ShareOther),
|
||||
channel, now,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
rows, err := tx.QueryContext(ctx, `
|
||||
SELECT PaketDepartemenID, ItemDepartemenID, ItemGudangID, ItemID, HeaderID,
|
||||
DepartemenID, SatuanID, SatuanDasarID, Jumlah, Harga, TipeDiskon, Diskon
|
||||
FROM paket_item WHERE PaketID = ? AND NA = 'N' ORDER BY PaketItemID`, packageID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items := make([]registrationPackageItem, 0)
|
||||
for rows.Next() {
|
||||
var item registrationPackageItem
|
||||
if err := rows.Scan(&item.PackageDepartmentID, &item.ItemDepartmentID, &item.ItemWarehouseID, &item.ItemID, &item.HeaderID, &item.DepartmentID, &item.UnitID, &item.BaseUnitID, &item.Quantity, &item.Price, &item.DiscountType, &item.Discount); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
rows.Close()
|
||||
for _, item := range items {
|
||||
var present int
|
||||
if err := tx.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM paket_dispenser_item WHERE PaketDispenserID = ? AND ItemID = ? AND HeaderID = ? AND NA = 'N')`, dispenserID, nullStringValue(item.ItemID), nullInt64Value(item.HeaderID)).Scan(&present); err != nil {
|
||||
return err
|
||||
}
|
||||
if present != 0 {
|
||||
continue
|
||||
}
|
||||
operationID, err := resolvePackageOperationIDForMCU(ctx, tx, nullStringFromInt(item.HeaderID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO paket_dispenser_item (
|
||||
PaketDispenserID, PaketDispenserOperasiID, RegID, MEDRECID, PaketID,
|
||||
PaketDepartemenID, ItemDepartemenID, ItemGudangID, ItemID, DepartemenID,
|
||||
HeaderID, SatuanID, SatuanDasarID, JumlahAwal, Jumlah, Harga, TipeDiskon,
|
||||
Diskon, LoginBuat, TanggalBuat, NA
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'N')`,
|
||||
dispenserID, operationID, regID, medrecID, packageID, nullInt64Value(item.PackageDepartmentID),
|
||||
nullInt64Value(item.ItemDepartmentID), nullInt64Value(item.ItemWarehouseID), nullStringValue(item.ItemID),
|
||||
nullStringValue(item.DepartmentID), nullInt64Value(item.HeaderID), nullStringValue(item.UnitID),
|
||||
nullStringValue(item.BaseUnitID), nullStringValue(item.Quantity), nullStringValue(item.Quantity),
|
||||
nullStringValue(item.Price), nullStringValue(item.DiscountType), nullStringValue(item.Discount), channel, now,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO paket_dispenser_adm (
|
||||
PaketAdmID, PaketDispenserID, RegID, PaketID, TipeRI, Hitung, AdmRI,
|
||||
AdmRIBaru, TipeDiskonRI, DiskonRI, LoginBuat, TanggalBuat, NA
|
||||
)
|
||||
SELECT pa.PaketAdmID, ?, ?, pa.PaketID, pa.TipeRI, 'Y', pa.AdmRI,
|
||||
pa.AdmRIBaru, pa.TipeDiskonRI, pa.DiskonRI, ?, ?, 'N'
|
||||
FROM paket_adm pa
|
||||
WHERE pa.PaketID = ? AND pa.NA = 'N'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM paket_dispenser_adm da
|
||||
WHERE da.PaketDispenserID = ? AND da.PaketAdmID = pa.PaketAdmID AND da.NA = 'N'
|
||||
)
|
||||
LIMIT 1`, dispenserID, regID, channel, now, packageID, dispenserID)
|
||||
return err
|
||||
}
|
||||
|
||||
func resolvePackageOperationIDForMCU(ctx context.Context, tx *sql.Tx, header sql.NullString) (int64, error) {
|
||||
if !header.Valid || strings.TrimSpace(header.String) == "" {
|
||||
return 0, nil
|
||||
}
|
||||
var operationID sql.NullInt64
|
||||
err := tx.QueryRowContext(ctx, `SELECT PaketDispenserOperasiID FROM paket_dispenser_operasi_header WHERE PaketOperasiHeaderID = ? LIMIT 1`, header.String).Scan(&operationID)
|
||||
if errors.Is(err, sql.ErrNoRows) || !operationID.Valid {
|
||||
return 0, nil
|
||||
}
|
||||
return operationID.Int64, err
|
||||
}
|
||||
|
||||
func nullStringFromInt(value sql.NullInt64) sql.NullString {
|
||||
if !value.Valid {
|
||||
return sql.NullString{}
|
||||
}
|
||||
return sql.NullString{String: strconv.FormatInt(value.Int64, 10), Valid: true}
|
||||
}
|
||||
|
||||
func findRegistrationPatient(ctx context.Context, tx *sql.Tx, params RegistrationParams) (MedrecPatient, bool, error) {
|
||||
query := medrecPatientSelect + ` WHERE NA = 'N' AND MEDRECID = ? LIMIT 1 FOR UPDATE`
|
||||
args := []any{params.MedrecID}
|
||||
if strings.TrimSpace(params.MedrecID) == "" {
|
||||
query = medrecPatientSelect + ` WHERE NA = 'N' AND JenisNomerSosial = 'KTP' AND NomerSosial = ? AND DATE(TanggalLahir) = ? LIMIT 1 FOR UPDATE`
|
||||
args = []any{params.NIK, params.BirthDate.Format("2006-01-02")}
|
||||
}
|
||||
patient, err := scanMedrecPatient(tx.QueryRowContext(ctx, query, args...))
|
||||
if err == sql.ErrNoRows {
|
||||
return MedrecPatient{}, false, nil
|
||||
}
|
||||
return patient, err == nil, err
|
||||
}
|
||||
|
||||
func findExistingRegistration(ctx context.Context, tx *sql.Tx, medrecID string, params RegistrationParams) (RegistrationRecord, bool, error) {
|
||||
query := `
|
||||
SELECT RegID, MEDRECID, DATE_FORMAT(Tanggal, '%Y-%m-%d'), DepartemenID, DokterID,
|
||||
JenisPasienID, JenisRegID, PeriodeTarifID, PerusahaanID, AsuransiID,
|
||||
ProviderID, GroupTarifID, NomerPolis, Pulang, BatalDaftar
|
||||
FROM regpas
|
||||
WHERE MEDRECID = ? AND DATE(Tanggal) = ? AND DepartemenID = ?
|
||||
AND Pulang = 'N' AND (BatalDaftar IS NULL OR BatalDaftar IN ('N', '')) AND NA = 'N'`
|
||||
args := []any{medrecID, params.RegistrationDate.Format("2006-01-02"), params.DepartmentID}
|
||||
if params.DoctorID != "" {
|
||||
query += ` AND DokterID = ?`
|
||||
args = append(args, params.DoctorID)
|
||||
}
|
||||
query += ` ORDER BY RegID DESC LIMIT 1 FOR UPDATE`
|
||||
|
||||
var registration RegistrationRecord
|
||||
err := tx.QueryRowContext(ctx, query, args...).Scan(
|
||||
®istration.RegID, ®istration.MEDRECID, ®istration.Tanggal, ®istration.DepartmentID,
|
||||
®istration.DoctorID, ®istration.PatientTypeID, ®istration.RegistrationID,
|
||||
®istration.TariffPeriodID, ®istration.CompanyID, ®istration.InsuranceID,
|
||||
®istration.ProviderID, ®istration.GroupTariffID, ®istration.PolicyNumber,
|
||||
®istration.Pulang, ®istration.BatalDaftar,
|
||||
)
|
||||
if err == sql.ErrNoRows {
|
||||
return RegistrationRecord{}, false, nil
|
||||
}
|
||||
return registration, err == nil, err
|
||||
}
|
||||
|
||||
func nextRegistrationID(ctx context.Context, tx *sql.Tx, prefix string) (string, error) {
|
||||
var lastID string
|
||||
err := tx.QueryRowContext(ctx, `SELECT RegID FROM regpas WHERE RegID LIKE ? ORDER BY RegID DESC LIMIT 1 FOR UPDATE`, prefix+"%").Scan(&lastID)
|
||||
if err == sql.ErrNoRows {
|
||||
return prefix + "00001", nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
number, err := strconv.Atoi(strings.TrimPrefix(lastID, prefix))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("%s%05d", prefix, number+1), nil
|
||||
}
|
||||
|
||||
func registrationIDPrefix(registrationType int64, registrationDate time.Time) string {
|
||||
prefix := "P"
|
||||
if registrationType == 2 {
|
||||
prefix = "MCU"
|
||||
}
|
||||
return prefix + registrationDate.Format("0601")
|
||||
}
|
||||
|
||||
func registrationOutcome(statusCode int, success bool, status, message string, data any) RegistrationOutcome {
|
||||
return RegistrationOutcome{HTTPStatus: statusCode, Success: success, Status: status, Message: message, Data: data}
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
17
internal/repository/registration_prefix_test.go
Normal file
17
internal/repository/registration_prefix_test.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRegistrationIDPrefix(t *testing.T) {
|
||||
registrationDate := time.Date(2026, time.September, 7, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
if got, want := registrationIDPrefix(2, registrationDate), "MCU2609"; got != want {
|
||||
t.Fatalf("MCU prefix = %q; want %q", got, want)
|
||||
}
|
||||
if got, want := registrationIDPrefix(1, registrationDate), "P2609"; got != want {
|
||||
t.Fatalf("regular prefix = %q; want %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -103,10 +103,15 @@ func (r MySQLLayananRepository) ListTarifKerjasama(ctx context.Context, params T
|
||||
query := `SELECT pt.Perusahaan_TarifID, pt.PerusahaanID, p.KodePerusahaan,
|
||||
p.Nama, pt.PenanggungJawab, p.Alamat, pt.TeleponPenanggungJawab,
|
||||
pt.EmailPenanggungJawab,
|
||||
(SELECT COUNT(*) FROM perusahaan_grouptarif pg
|
||||
WHERE pg.Perusahaan_TarifID = pt.Perusahaan_TarifID AND pg.NA = 'N')
|
||||
COALESCE(pg.JumlahGroupTarif, 0)
|
||||
FROM perusahaan_tarif pt
|
||||
INNER JOIN perusahaan p ON p.PerusahaanID = pt.PerusahaanID ` + where + `
|
||||
INNER JOIN perusahaan p ON p.PerusahaanID = pt.PerusahaanID
|
||||
LEFT JOIN (
|
||||
SELECT Perusahaan_TarifID, COUNT(*) AS JumlahGroupTarif
|
||||
FROM perusahaan_grouptarif
|
||||
WHERE NA = 'N'
|
||||
GROUP BY Perusahaan_TarifID
|
||||
) pg ON pg.Perusahaan_TarifID = pt.Perusahaan_TarifID ` + where + `
|
||||
ORDER BY p.Nama, pt.Perusahaan_TarifID
|
||||
LIMIT ? OFFSET ?`
|
||||
queryArgs := append(append([]any{}, args...), params.PerPage, (params.Page-1)*params.PerPage)
|
||||
@@ -282,8 +287,8 @@ func (r MySQLLayananRepository) tarifKerjasamaLayanan(ctx context.Context, group
|
||||
query := fmt.Sprintf(`SELECT tarif.TarifLayananID, CAST(tarif.LayananID AS CHAR),
|
||||
layanan.Nama, CAST(tarif.JenisLayananID AS CHAR), jenis.Nama,
|
||||
CAST(tarif.KelasID AS CHAR), 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),
|
||||
tarif.TipeDiskon, CAST(tarif.Diskon AS CHAR), '' AS TipeDiskonRI,
|
||||
'0' AS DiskonRI, 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
|
||||
|
||||
125
report/xx-build-deploy-cpone-middleware.md
Normal file
125
report/xx-build-deploy-cpone-middleware.md
Normal file
@@ -0,0 +1,125 @@
|
||||
# Build & Deploy CPONE Middleware — devcpone.aplikasi.web.id
|
||||
|
||||
## Ringkasan
|
||||
|
||||
Build lokal commit `1ee0cfb` (HEAD) dikirim dan di-deploy ke
|
||||
`one@devcpone.aplikasi.web.id`, lalu service `cpone-middleware.service`
|
||||
di-restart. Service aktif dan endpoint sudah diverifikasi.
|
||||
|
||||
## Hasil Build
|
||||
|
||||
| Item | Nilai |
|
||||
| :--- | :--- |
|
||||
| Target build | `./cmd/server` |
|
||||
| Platform | linux/amd64 |
|
||||
| Flags | `CGO_ENABLED=0 -trimpath -ldflags="-s -w"` |
|
||||
| Ukuran binary | 7,159,992 bytes (statically linked, stripped) |
|
||||
| SHA-256 | `0287e519926ba44c08df0d2a137930ae60028bfb49822139dd3e578864ab008e` |
|
||||
|
||||
Test dan vet sebelum build:
|
||||
|
||||
```text
|
||||
go test ./... -> ok
|
||||
go vet ./... -> ok
|
||||
```
|
||||
|
||||
## Konfigurasi Server
|
||||
|
||||
Build `HEAD` memakai arsitektur baru: database management (`CPONE_DB_*`)
|
||||
plus registry koneksi per rumah sakit, menggantikan koneksi tunggal `DB_*`.
|
||||
Karena itu `.env` server di-update.
|
||||
|
||||
| Kunci lama | Status | Keterangan |
|
||||
| :--- | :--- | :--- |
|
||||
| `DB_HOST/DB_PORT/DB_DATABASE/DB_USERNAME/DB_PASSWORD` | dihapus | Tidak dipakai build baru |
|
||||
| `CPONE_DB_HOST/PORT/DATABASE/USERNAME/PASSWORD` | baru | Management DB lokal MySQL |
|
||||
| `CPONE_BEARER_TOKEN` | tetap | `MYmIZGwkLQUjmFHiL2kgrOjhUyZnk507oBD2BDwM` |
|
||||
| `CPONE_DATABASE_SETTINGS_TOKEN` | tidak diset | Fallback ke bearer token |
|
||||
| `CPONE_DATABASE_CREDENTIAL_KEY` | tidak diset | Fallback ke bearer token |
|
||||
|
||||
Nilai management DB baru di `/home/one/apps/.env`:
|
||||
|
||||
| Kunci | Nilai |
|
||||
| :--- | :--- |
|
||||
| `CPONE_DB_HOST` | `127.0.0.1` |
|
||||
| `CPONE_DB_PORT` | `3306` |
|
||||
| `CPONE_DB_DATABASE` | `cpone_middleware` |
|
||||
| `CPONE_DB_USERNAME` | `admin` |
|
||||
| `CPONE_DB_PASSWORD` | `Sasone!102938` |
|
||||
|
||||
Pada startup, service otomatis membuat database `cpone_middleware` beserta
|
||||
tabel `hospitals` dan `hospital_databases` (user `admin` punya hak
|
||||
`CREATE DATABASE`).
|
||||
|
||||
## Setting Rumah Sakit
|
||||
|
||||
Disamakan dengan environment lokal developer. Di-seed lewat API
|
||||
`POST /api/cpone/database-settings` setelah service jalan:
|
||||
|
||||
| Field | Nilai |
|
||||
| :--- | :--- |
|
||||
| `kode_rs` | `AWALBROS_DEV` |
|
||||
| `nama` | `RS Dev Awalbros` |
|
||||
| `host` | `his.sismedika.online` |
|
||||
| `port` | `3306` |
|
||||
| `database` | `rsabt201107` |
|
||||
| `username` | `remote` |
|
||||
|
||||
Password HIS disimpan terenkripsi (AES-GCM) di tabel `hospital_databases`;
|
||||
response API hanya menampilkan `has_password`.
|
||||
|
||||
## Verifikasi
|
||||
|
||||
| Cek | Hasil |
|
||||
| :--- | :--- |
|
||||
| `GET /healthz` | `{"success":true,"message":"ok","data":null}` |
|
||||
| `GET /api/cpone/hospitals` | Mengembalikan `AWALBROS_DEV` |
|
||||
| `GET /api/cpone/layanan?page=1` + `X-RS-Code` | Data HIS ter-load |
|
||||
| Tanpa `X-RS-Code` | 422 sesuai desain baru |
|
||||
| Service state | `active (running)`, MainPID stabil |
|
||||
|
||||
## Cara Restart (tanpa sudo)
|
||||
|
||||
`sudo systemctl restart cpone-middleware` butuh password (hanya
|
||||
`apache2.service` yang NOPASSWD). Service berjalan sebagai user `one` dan
|
||||
memakai `Restart=always`, jadi restart dilakukan dengan `kill` pada PID
|
||||
utama; systemd otomatis start ulang dengan binary baru:
|
||||
|
||||
```bash
|
||||
kill $(systemctl show cpone-middleware.service -p MainPID --value)
|
||||
```
|
||||
|
||||
## Backup & Rollback
|
||||
|
||||
File cadangan di `/home/one/apps/`:
|
||||
|
||||
| File | Isi |
|
||||
| :--- | :--- |
|
||||
| `cpone-middleware.before-1ee0cfb` | Binary lama (commit 3e5cae4) |
|
||||
| `.env.bak-20260828` | .env lama (koneksi DB tunggal) |
|
||||
|
||||
Rollback:
|
||||
|
||||
```bash
|
||||
cd /home/one/apps
|
||||
cp -p cpone-middleware.before-1ee0cfb cpone-middleware
|
||||
cp -p .env.bak-20260828 .env
|
||||
kill $(systemctl show cpone-middleware.service -p MainPID --value)
|
||||
```
|
||||
|
||||
## Catatan Penting
|
||||
|
||||
Build baru WAJIB mengirim `X-RS-Code` (atau query `?kode_rs=`) pada semua
|
||||
endpoint bisnis. Controller PHP yang memanggil `127.0.0.1:8282/api/cpone`
|
||||
saat ini belum mengirim header tersebut, sehingga akan menerima 422 sampai
|
||||
controller PHP di-update untuk mengirim `X-RS-Code: AWALBROS_DEV`.
|
||||
|
||||
Untuk production, sebaiknya set `CPONE_DATABASE_SETTINGS_TOKEN` dan
|
||||
`CPONE_DATABASE_CREDENTIAL_KEY` dengan nilai berbeda dari bearer token,
|
||||
serta pakai user MySQL khusus (bukan `admin`).
|
||||
|
||||
## Documentation Updates Needed
|
||||
|
||||
### docs/ (tidak ada perubahan perlu; repo tidak punya direktori docs)
|
||||
|
||||
Tidak ada file dokumentasi yang perlu diubah di repository ini.
|
||||
189
scripts/deploy.sh
Executable file
189
scripts/deploy.sh
Executable file
@@ -0,0 +1,189 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
remote_target="${1:-}"
|
||||
app_name="${APP_NAME:-cpone-middleware}"
|
||||
build_binary="${BUILD_BINARY:-dist/${app_name}-linux-amd64}"
|
||||
requested_remote_dir="${REMOTE_DIR:-auto}"
|
||||
remote_binary_override="${REMOTE_BINARY:-}"
|
||||
service="${SERVICE:-${app_name}.service}"
|
||||
service_scope="${SERVICE_SCOPE:-auto}"
|
||||
restart_mode="${RESTART_MODE:-systemd-kill}"
|
||||
ssh_timeout="${SSH_TIMEOUT:-45s}"
|
||||
transfer_timeout="${TRANSFER_TIMEOUT:-120s}"
|
||||
|
||||
if [[ -z "$remote_target" ]]; then
|
||||
printf 'Error: target server belum diisi.\n' >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [[ ! "$remote_target" =~ ^[A-Za-z0-9._-]+@[A-Za-z0-9._:-]+$ ]]; then
|
||||
printf 'Error: target harus berbentuk user@host (gunakan SSH_OPTIONS untuk port).\n' >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [[ ! -f "$build_binary" ]]; then
|
||||
printf 'Error: binary tidak ditemukan: %s\n' "$build_binary" >&2
|
||||
printf 'Jalankan `make build` terlebih dahulu.\n' >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
case "$restart_mode" in
|
||||
systemd-kill|systemctl) ;;
|
||||
*)
|
||||
printf 'Error: RESTART_MODE harus systemd-kill atau systemctl.\n' >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
case "$service_scope" in
|
||||
auto|user|system) ;;
|
||||
*)
|
||||
printf 'Error: SERVICE_SCOPE harus auto, user, atau system.\n' >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
if ! command -v ssh >/dev/null 2>&1 || ! command -v scp >/dev/null 2>&1 || ! command -v timeout >/dev/null 2>&1; then
|
||||
printf 'Error: ssh, scp, dan timeout wajib tersedia di komputer lokal.\n' >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
read -r -a ssh_options <<< "${SSH_OPTIONS:-}"
|
||||
|
||||
remote_metadata="$(timeout --foreground "$ssh_timeout" ssh "${ssh_options[@]}" "$remote_target" bash -s -- "$service" "$service_scope" <<'REMOTE_METADATA'
|
||||
set -Eeuo pipefail
|
||||
|
||||
service="$1"
|
||||
requested_scope="$2"
|
||||
|
||||
if [[ "$requested_scope" == "user" ]]; then
|
||||
systemctl_cmd=(systemctl --user)
|
||||
resolved_scope=user
|
||||
elif [[ "$requested_scope" == "system" ]]; then
|
||||
systemctl_cmd=(systemctl)
|
||||
resolved_scope=system
|
||||
elif systemctl --user show "$service" >/dev/null 2>&1; then
|
||||
systemctl_cmd=(systemctl --user)
|
||||
resolved_scope=user
|
||||
elif systemctl show "$service" >/dev/null 2>&1; then
|
||||
systemctl_cmd=(systemctl)
|
||||
resolved_scope=system
|
||||
else
|
||||
echo "Service tidak ditemukan: $service" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
working_directory="$("${systemctl_cmd[@]}" show "$service" -p WorkingDirectory --value 2>/dev/null || true)"
|
||||
printf '%s\t%s\n' "$resolved_scope" "$working_directory"
|
||||
REMOTE_METADATA
|
||||
)"
|
||||
IFS=$'\t' read -r resolved_service_scope service_working_directory <<< "$remote_metadata"
|
||||
|
||||
if [[ "$resolved_service_scope" != user && "$resolved_service_scope" != system ]]; then
|
||||
printf 'Error: scope service remote tidak valid: %s\n' "$resolved_service_scope" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$requested_remote_dir" == "auto" ]]; then
|
||||
if [[ -n "$service_working_directory" && "$service_working_directory" != "-" ]]; then
|
||||
remote_dir="${service_working_directory%/}"
|
||||
else
|
||||
remote_home="$(timeout --foreground "$ssh_timeout" ssh "${ssh_options[@]}" "$remote_target" 'printf "%s" "$HOME"')"
|
||||
if [[ -z "$remote_home" ]]; then
|
||||
printf 'Error: tidak dapat menentukan HOME pada server remote.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
remote_dir="${remote_home%/}/apps"
|
||||
fi
|
||||
else
|
||||
remote_dir="$requested_remote_dir"
|
||||
fi
|
||||
|
||||
remote_binary="${remote_binary_override:-${remote_dir}/${app_name}}"
|
||||
remote_tmp="${remote_dir}/.${app_name}.new.$$.$RANDOM"
|
||||
cleanup_needed=0
|
||||
|
||||
cleanup_remote_tmp() {
|
||||
if (( cleanup_needed )); then
|
||||
timeout --foreground "$ssh_timeout" ssh "${ssh_options[@]}" "$remote_target" bash -s -- "$remote_tmp" <<'REMOTE_CLEANUP' >/dev/null 2>&1 || true
|
||||
set -eu
|
||||
rm -f -- "$1"
|
||||
REMOTE_CLEANUP
|
||||
fi
|
||||
}
|
||||
trap cleanup_remote_tmp EXIT
|
||||
|
||||
printf 'Preparing remote directory %s:%s\n' "$remote_target" "$remote_dir"
|
||||
timeout --foreground "$ssh_timeout" ssh "${ssh_options[@]}" "$remote_target" bash -s -- "$remote_dir" <<'REMOTE_PREPARE'
|
||||
set -eu
|
||||
install -d -- "$1"
|
||||
REMOTE_PREPARE
|
||||
|
||||
printf 'Uploading %s -> %s:%s\n' "$build_binary" "$remote_target" "$remote_tmp"
|
||||
timeout --foreground "$transfer_timeout" scp "${ssh_options[@]}" "$build_binary" "$remote_target:$remote_tmp"
|
||||
cleanup_needed=1
|
||||
|
||||
printf 'Installing binary and restarting %s\n' "$service"
|
||||
timeout --foreground "$ssh_timeout" ssh "${ssh_options[@]}" "$remote_target" bash -s -- \
|
||||
"$remote_binary" "$service" "$remote_tmp" "$restart_mode" "$resolved_service_scope" <<'REMOTE_INSTALL'
|
||||
set -Eeuo pipefail
|
||||
|
||||
binary="$1"
|
||||
service="$2"
|
||||
tmp="$3"
|
||||
restart_mode="$4"
|
||||
service_scope="$5"
|
||||
main_pid=""
|
||||
|
||||
if [[ "$service_scope" == "user" ]]; then
|
||||
systemctl_cmd=(systemctl --user)
|
||||
else
|
||||
systemctl_cmd=(systemctl)
|
||||
fi
|
||||
|
||||
if [[ ! -s "$tmp" ]]; then
|
||||
echo "Uploaded binary kosong atau tidak ditemukan: $tmp" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$restart_mode" == "systemd-kill" ]]; then
|
||||
main_pid="$("${systemctl_cmd[@]}" show "$service" -p MainPID --value 2>/dev/null || true)"
|
||||
if [[ ! "$main_pid" =~ ^[1-9][0-9]*$ ]]; then
|
||||
echo "Tidak dapat menemukan MainPID aktif untuk $service; binary belum diganti." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
chmod 0755 "$tmp"
|
||||
if [[ -e "$binary" ]]; then
|
||||
backup="${binary}.before-$(date +%Y%m%d%H%M%S)-$$"
|
||||
cp -p -- "$binary" "$backup"
|
||||
echo "Backup: $backup"
|
||||
fi
|
||||
mv -f -- "$tmp" "$binary"
|
||||
|
||||
if [[ "$restart_mode" == "systemctl" ]]; then
|
||||
"${systemctl_cmd[@]}" restart "$service"
|
||||
else
|
||||
kill -TERM "$main_pid"
|
||||
fi
|
||||
|
||||
for attempt in {1..20}; do
|
||||
if "${systemctl_cmd[@]}" is-active --quiet "$service"; then
|
||||
new_pid="$("${systemctl_cmd[@]}" show "$service" -p MainPID --value 2>/dev/null || true)"
|
||||
if [[ "$restart_mode" == "systemctl" || "$new_pid" != "$main_pid" ]]; then
|
||||
echo "Service aktif: $service (MainPID=${new_pid:-unknown})"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo "Service tidak kembali aktif dalam 20 detik: $service" >&2
|
||||
"${systemctl_cmd[@]}" status "$service" --no-pager >&2 || true
|
||||
exit 1
|
||||
REMOTE_INSTALL
|
||||
cleanup_needed=0
|
||||
|
||||
printf 'Deploy selesai ke %s\n' "$remote_target"
|
||||
Reference in New Issue
Block a user