Compare commits

...

9 Commits

36 changed files with 6165 additions and 96 deletions

View File

@@ -1,11 +1,17 @@
APP_HOST=0.0.0.0 APP_HOST=0.0.0.0
APP_PORT=8080 APP_PORT=8282
# Database khusus master CPONE middleware. # 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_HOST=127.0.0.1
CPONE_DB_PORT=3306 CPONE_DB_PORT=3306
CPONE_DB_DATABASE=cpone_middleware CPONE_DB_DATABASE=nama_database_his
CPONE_DB_USERNAME=root CPONE_DB_USERNAME=cpone
CPONE_DB_PASSWORD= CPONE_DB_PASSWORD=
DB_MAX_OPEN_CONNS=25 DB_MAX_OPEN_CONNS=25
@@ -13,5 +19,3 @@ DB_MAX_IDLE_CONNS=25
DB_CONN_MAX_LIFETIME_MINUTES=5 DB_CONN_MAX_LIFETIME_MINUTES=5
CPONE_BEARER_TOKEN=replace-with-a-secure-token CPONE_BEARER_TOKEN=replace-with-a-secure-token
CPONE_DATABASE_SETTINGS_TOKEN=replace-with-a-different-admin-token
CPONE_DATABASE_CREDENTIAL_KEY=replace-with-a-long-random-encryption-key

78
Makefile Normal file
View 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
View File

@@ -1,5 +1,47 @@
# CPONE Middleware Go # 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: Service Go untuk endpoint:
```text ```text
@@ -7,6 +49,10 @@ GET /api/cpone/paket
GET /api/cpone/paket/{id} GET /api/cpone/paket/{id}
Authorization: Bearer <your-secure-token> Authorization: Bearer <your-secure-token>
POST /api/cpone/paket
Authorization: Bearer <your-secure-token>
Content-Type: application/json
GET /api/cpone/layanan?page=1 GET /api/cpone/layanan?page=1
Authorization: Bearer <your-secure-token> Authorization: Bearer <your-secure-token>
X-RS-Code: <kode-rs> X-RS-Code: <kode-rs>
@@ -17,6 +63,14 @@ GET /api/cpone/perusahaan/{id}/group-tarif?group_tarif_id=
Authorization: Bearer <your-secure-token> Authorization: Bearer <your-secure-token>
X-RS-Code: <kode-rs> 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 GET /api/cpone/tarif-kerjasama?search=&perusahaan_id=&tarif_id=&page=1&per_page=15
Authorization: Bearer <your-secure-token> Authorization: Bearer <your-secure-token>
@@ -41,6 +95,10 @@ dipaginasi 20 data per halaman.
GET /api/cpone/layanan-mapping-harga?periodeTarifID=10&layananID=6413,6414,1309 GET /api/cpone/layanan-mapping-harga?periodeTarifID=10&layananID=6413,6414,1309
Authorization: Bearer <your-secure-token> 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 POST /api/cpone/patients/lab-registration
Authorization: Bearer <your-secure-token> Authorization: Bearer <your-secure-token>
Content-Type: application/json Content-Type: application/json
@@ -60,11 +118,72 @@ Content-Type: application/json
Format payload dan response dibuat mengikuti controller PHP CPONE terkait. 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: Contoh payload pembuatan pasien HIS:
```json ```json
{ {
"rs": "PRIMAYA",
"nama": "BUDI SANTOS", "nama": "BUDI SANTOS",
"jenis_kelamin": "L", "jenis_kelamin": "L",
"tgl_lahir": "1990-01-15", "tgl_lahir": "1990-01-15",
@@ -91,6 +210,40 @@ Contoh payload pembuatan pasien HIS:
Endpoint mengembalikan `created`, `already_exists`, atau `possible_duplicate` Endpoint mengembalikan `created`, `already_exists`, atau `possible_duplicate`
sesuai hasil pemeriksaan identitas dan kemiripan data pasien di HIS. 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: Contoh payload registrasi laboratorium:
```json ```json
@@ -154,89 +307,45 @@ bersifat idempoten dan tidak membuat transaksi ganda.
## Config ## 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 ```text
APP_HOST=0.0.0.0 APP_HOST=0.0.0.0
APP_PORT=8080 APP_PORT=8080
CPONE_RS_CODE=AWALBROS_DEV
CPONE_DEPARTEMEN_ID=08_01
CPONE_DB_HOST=127.0.0.1 CPONE_DB_HOST=127.0.0.1
CPONE_DB_PORT=3306 CPONE_DB_PORT=3306
CPONE_DB_DATABASE=cpone_middleware CPONE_DB_DATABASE=nama_database_his
CPONE_DB_USERNAME=root CPONE_DB_USERNAME=cpone
CPONE_DB_PASSWORD= CPONE_DB_PASSWORD=
CPONE_BEARER_TOKEN=replace-with-a-secure-token CPONE_BEARER_TOKEN=replace-with-a-secure-token
CPONE_DATABASE_SETTINGS_TOKEN=replace-with-a-different-admin-token
CPONE_DATABASE_CREDENTIAL_KEY=replace-with-a-long-random-encryption-key
``` ```
## Multi-database per kode RS `CPONE_DB_*` adalah koneksi database HIS, bukan database management CPONE.
Password hanya dibaca dari `.env`; aplikasi tidak menyimpan kredensial database
ke database lain.
Tambahkan atau perbarui koneksi RS melalui API berikut. Koneksi akan dites `CPONE_DEPARTEMEN_ID` adalah `DepartemenID` HIS yang digunakan untuk registrasi
sebelum disimpan; bila `Ping` gagal, setting tidak disimpan. laboratorium. Nilai ini diambil dari konfigurasi, sehingga tidak perlu dikirim
di payload endpoint registrasi.
```http Semua endpoint bisnis tetap membutuhkan header:
POST /api/cpone/database-settings
Authorization: Bearer <your-database-settings-token>
Content-Type: application/json
{
"kode_rs": "PRIMAYA_BEKASI_BARAT",
"nama": "Primaya Hospital Bekasi Barat",
"host": "10.10.10.20",
"port": "3306",
"database": "his_bekasi_barat",
"username": "cpone",
"password": "secret"
}
```
Endpoint pengelolaan yang tersedia:
```text
POST /api/cpone/database-settings
GET /api/cpone/database-settings
GET /api/cpone/database-settings/{kodeRs}
```
Daftar ringkas rumah sakit aktif dapat diambil tanpa memilih database HIS:
```http
GET /api/cpone/hospitals
Authorization: Bearer <your-secure-token>
```
Response hanya memuat `kode_rs`, `nama`, dan `status`; rumah sakit nonaktif tidak
ditampilkan.
Saat startup, service otomatis membuat database `CPONE_DB_DATABASE` beserta
tabel `hospitals` dan `hospital_databases`. Tidak ada database RS default; semua
koneksi HIS harus ditambahkan lewat API. Akun `CPONE_DB_USERNAME` harus memiliki
izin `CREATE DATABASE` pada bootstrap pertama; alternatifnya jalankan migration
di direktori `database/migrations` secara manual.
Response list/detail tidak pernah menampilkan password, hanya field
`has_password`. Password koneksi RS disimpan terenkripsi AES-GCM menggunakan
`CPONE_DATABASE_CREDENTIAL_KEY`. Jangan mengubah key tersebut setelah data mulai
disimpan karena password lama tidak akan bisa didekripsi. Jika key belum diisi,
service memakai `CPONE_DATABASE_SETTINGS_TOKEN`, lalu `CPONE_BEARER_TOKEN`
sebagai fallback; token fallback itu juga tidak boleh diubah tanpa rotasi
credential.
Endpoint pengelolaan memakai `CPONE_DATABASE_SETTINGS_TOKEN`. Jika variabel ini
belum diisi, service memakai `CPONE_BEARER_TOKEN` untuk kompatibilitas; gunakan
token admin yang berbeda di production.
Pilih database pada semua endpoint bisnis dengan header:
```http ```http
X-RS-Code: PRIMAYA_BEKASI_BARAT X-RS-Code: PRIMAYA_BEKASI_BARAT
``` ```
Sebagai alternatif dapat memakai query `?kode_rs=PRIMAYA_BEKASI_BARAT`. Sebagai alternatif dapat memakai query `?kode_rs=AWALBROS_DEV`. Header memiliki
Header memiliki prioritas lebih tinggi daripada query. Response juga memuat prioritas lebih tinggi daripada query. Kode tersebut hanya divalidasi terhadap
header `X-RS-Code` agar koneksi yang terpilih mudah diaudit. Salah satu dari `CPONE_RS_CODE`; kode tidak digunakan untuk memilih database.
header atau query tersebut wajib dikirim; jika tidak ada, API mengembalikan
status `422` dan tidak mengakses database HIS. 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 ## Run

View File

@@ -35,28 +35,39 @@ func main() {
} }
defer resolver.Close() defer resolver.Close()
handler := apphttp.NewDynamicHandler(cfg.RSCode) handler := apphttp.NewDynamicHandler(cfg.RSCode, cfg.DepartmentID)
protected := func(handler http.Handler) http.Handler { protected := func(handler http.Handler) http.Handler {
return apphttp.BearerAuth(cfg.BearerToken, apphttp.SelectDatabase(resolver, handler)) return apphttp.BearerAuth(cfg.BearerToken, apphttp.SelectDatabase(resolver, handler))
} }
mux := http.NewServeMux() mux := http.NewServeMux()
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", protected(http.HandlerFunc(handler.ListPaket)))
mux.Handle("GET /api/cpone/paket/{id}", protected(http.HandlerFunc(handler.GetPaket))) mux.Handle("GET /api/cpone/paket/{id}", protected(http.HandlerFunc(handler.GetPaket)))
mux.Handle("GET /api/cpone/layanan", protected(http.HandlerFunc(handler.ListLayanan))) mux.Handle("GET /api/cpone/layanan", protected(http.HandlerFunc(handler.ListLayanan)))
mux.Handle("GET /api/cpone/perusahaan", protected(http.HandlerFunc(handler.ListPerusahaan))) 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}", protected(http.HandlerFunc(handler.GetPerusahaan)))
mux.Handle("GET /api/cpone/perusahaan/{id}/group-tarif", protected(http.HandlerFunc(handler.GetPerusahaanGroupTarif))) 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", protected(http.HandlerFunc(handler.ListTarifKerjasama)))
mux.Handle("GET /api/cpone/tarif-kerjasama/{id}", protected(http.HandlerFunc(handler.GetTarifKerjasama))) mux.Handle("GET /api/cpone/tarif-kerjasama/{id}", protected(http.HandlerFunc(handler.GetTarifKerjasama)))
mux.Handle("GET /api/cpone/periode-tarif", protected(http.HandlerFunc(handler.ListPeriodeTarif))) 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/periode-tarif/{periodeTarifID}", protected(http.HandlerFunc(handler.ListPeriodeTarifLayanan)))
mux.Handle("GET /api/cpone/layanan-mapping-harga", protected(http.HandlerFunc(handler.MappingHarga))) mux.Handle("GET /api/cpone/layanan-mapping-harga", protected(http.HandlerFunc(handler.MappingHarga)))
mux.Handle("POST /api/cpone/patients/medrec", protected(http.HandlerFunc(handler.StoreMedrec))) 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", 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}/services", protected(http.HandlerFunc(handler.AddLabServices)))
mux.Handle("POST /api/cpone/patients/lab-registration/{regId}/packages", protected(http.HandlerFunc(handler.AddLabPackage))) 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))) 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) { mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)

Binary file not shown.

View 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
View 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

File diff suppressed because it is too large Load Diff

1258
docs/resume_mcu.php Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
Kode Layanan RS,Kode Test CPONE,Nama Layanan RS
1 Kode Layanan RS Kode Test CPONE Nama Layanan RS

Binary file not shown.

View File

@@ -13,6 +13,7 @@ type Config struct {
AppHost string AppHost string
AppPort string AppPort string
RSCode string RSCode string
DepartmentID string
BearerToken string BearerToken string
DBMaxOpenConns int DBMaxOpenConns int
DBMaxIdleConns int DBMaxIdleConns int
@@ -33,6 +34,7 @@ func Load(path string) (Config, error) {
AppHost: env("APP_HOST", "0.0.0.0"), AppHost: env("APP_HOST", "0.0.0.0"),
AppPort: env("APP_PORT", "8080"), AppPort: env("APP_PORT", "8080"),
RSCode: env("CPONE_RS_CODE", ""), RSCode: env("CPONE_RS_CODE", ""),
DepartmentID: env("CPONE_DEPARTEMEN_ID", ""),
BearerToken: env("CPONE_BEARER_TOKEN", ""), BearerToken: env("CPONE_BEARER_TOKEN", ""),
DBMaxOpenConns: envInt("DB_MAX_OPEN_CONNS", 25), DBMaxOpenConns: envInt("DB_MAX_OPEN_CONNS", 25),
DBMaxIdleConns: envInt("DB_MAX_IDLE_CONNS", 25), DBMaxIdleConns: envInt("DB_MAX_IDLE_CONNS", 25),
@@ -47,6 +49,9 @@ func Load(path string) (Config, error) {
if cfg.RSCode == "" { if cfg.RSCode == "" {
return Config{}, fmt.Errorf("CPONE_RS_CODE wajib diisi") return Config{}, fmt.Errorf("CPONE_RS_CODE wajib diisi")
} }
if cfg.DepartmentID == "" {
return Config{}, fmt.Errorf("CPONE_DEPARTEMEN_ID wajib diisi")
}
if cfg.BearerToken == "" { if cfg.BearerToken == "" {
return Config{}, fmt.Errorf("CPONE_BEARER_TOKEN wajib diisi") return Config{}, fmt.Errorf("CPONE_BEARER_TOKEN wajib diisi")
} }

103
internal/http/dokter.go Normal file
View 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
}

View 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)
}
}
}

View File

@@ -27,6 +27,10 @@ type PatientRepository interface {
StoreMedrec(ctx context.Context, params repository.MedrecParams) (repository.MedrecOutcome, error) 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 { type LabResultRepository interface {
GetLabResult(ctx context.Context, labNumber string) (repository.LabResultOutcome, error) GetLabResult(ctx context.Context, labNumber string) (repository.LabResultOutcome, error)
} }
@@ -34,6 +38,7 @@ type LabResultRepository interface {
type PaketRepository interface { type PaketRepository interface {
ListPaket(ctx context.Context, params repository.PaketListParams) (repository.PaketPage, error) ListPaket(ctx context.Context, params repository.PaketListParams) (repository.PaketPage, error)
GetPaket(ctx context.Context, id string) (*repository.PaketDetail, error) GetPaket(ctx context.Context, id string) (*repository.PaketDetail, error)
CreatePaket(ctx context.Context, params repository.CreatePaketParams) (repository.CreatePaketResult, error)
} }
type TarifKerjasamaRepository interface { type TarifKerjasamaRepository interface {
@@ -55,41 +60,66 @@ type PerusahaanRepository interface {
GetPerusahaanGroupTarif(ctx context.Context, id, groupTarifID string) (*repository.PerusahaanGroupTarifDetail, 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 { type Handler struct {
layananRepo LayananRepository layananRepo LayananRepository
labRepo LabRegistrationRepository labRepo LabRegistrationRepository
patientRepo PatientRepository patientRepo PatientRepository
registrationRepo RegistrationRepository
labResultRepo LabResultRepository labResultRepo LabResultRepository
paketRepo PaketRepository paketRepo PaketRepository
tarifRepo TarifKerjasamaRepository tarifRepo TarifKerjasamaRepository
periodeTarifRepo PeriodeTarifRepository periodeTarifRepo PeriodeTarifRepository
periodeTarifDetailRepo PeriodeTarifDetailRepository periodeTarifDetailRepo PeriodeTarifDetailRepository
perusahaanRepo PerusahaanRepository perusahaanRepo PerusahaanRepository
dokterRepo DokterRepository
laporanMCURepo LaporanMCURepository
hospitalCode string hospitalCode string
departmentID string
} }
type CPONERepository interface { type CPONERepository interface {
LayananRepository LayananRepository
LabRegistrationRepository LabRegistrationRepository
PatientRepository PatientRepository
RegistrationRepository
LabResultRepository LabResultRepository
PaketRepository PaketRepository
TarifKerjasamaRepository TarifKerjasamaRepository
PeriodeTarifRepository PeriodeTarifRepository
PeriodeTarifDetailRepository PeriodeTarifDetailRepository
PerusahaanRepository PerusahaanRepository
DokterRepository
} }
func NewHandler(repo CPONERepository) Handler { func NewHandler(repo CPONERepository) Handler {
return Handler{ handler := Handler{
layananRepo: repo, labRepo: repo, patientRepo: repo, layananRepo: repo, labRepo: repo, patientRepo: repo, registrationRepo: repo,
labResultRepo: repo, paketRepo: repo, tarifRepo: repo, labResultRepo: repo, paketRepo: repo, tarifRepo: repo,
periodeTarifRepo: repo, periodeTarifDetailRepo: repo, perusahaanRepo: 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) Handler { func NewDynamicHandler(hospitalCode string, departmentID string) Handler {
return Handler{hospitalCode: strings.ToUpper(strings.TrimSpace(hospitalCode))} return Handler{
hospitalCode: strings.ToUpper(strings.TrimSpace(hospitalCode)),
departmentID: strings.TrimSpace(departmentID),
}
} }
func (h Handler) layananRepository(ctx context.Context) LayananRepository { func (h Handler) layananRepository(ctx context.Context) LayananRepository {
@@ -113,6 +143,13 @@ func (h Handler) patientRepository(ctx context.Context) PatientRepository {
return h.patientRepo 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 { func (h Handler) labResultRepository(ctx context.Context) LabResultRepository {
if repo, ok := selectedRepository(ctx); ok { if repo, ok := selectedRepository(ctx); ok {
return repo return repo
@@ -155,6 +192,20 @@ func (h Handler) perusahaanRepository(ctx context.Context) PerusahaanRepository
return h.perusahaanRepo 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) { func (h Handler) MappingHarga(w http.ResponseWriter, r *http.Request) {
layananIDs := parseLayananIDs(r) layananIDs := parseLayananIDs(r)
if len(layananIDs) == 0 { if len(layananIDs) == 0 {

View File

@@ -35,6 +35,11 @@ func (h Handler) RegisterLab(w http.ResponseWriter, r *http.Request) {
writeLabResponse(w, http.StatusUnprocessableEntity, false, "validation_error", err.Error(), nil) writeLabResponse(w, http.StatusUnprocessableEntity, false, "validation_error", err.Error(), nil)
return 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) validated, validationMessage := validateLabRegistration(payload)
if validationMessage != "" { if validationMessage != "" {

View File

@@ -103,6 +103,36 @@ 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) { func TestRegisterLabCompanyPayload(t *testing.T) {
repo := &fakeLabRegistrationRepository{outcome: repository.LabRegistrationOutcome{ repo := &fakeLabRegistrationRepository{outcome: repository.LabRegistrationOutcome{
HTTPStatus: http.StatusCreated, HTTPStatus: http.StatusCreated,

View 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))
}

View 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: &regID}}}
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())
}
}

View 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})
}

View 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)
}
})
}
}

View File

@@ -12,11 +12,13 @@ import (
) )
type fakePaketRepository struct { type fakePaketRepository struct {
page repository.PaketPage page repository.PaketPage
detail *repository.PaketDetail detail *repository.PaketDetail
err error create repository.CreatePaketResult
listParams repository.PaketListParams err error
detailID string listParams repository.PaketListParams
detailID string
createParams repository.CreatePaketParams
} }
func (f *fakePaketRepository) ListPaket(_ context.Context, params repository.PaketListParams) (repository.PaketPage, error) { 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 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) { func TestListPaketResponseParity(t *testing.T) {
kodeCorp := "ANP" kodeCorp := "ANP"
jenisPaket := "Medical Check Up" jenisPaket := "Medical Check Up"

View 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})
}

View 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())
}
}

View 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
}

View 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{
&regID, &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]
}

View File

@@ -52,6 +52,7 @@ type PossibleDuplicateData struct {
type MedrecPatient struct { type MedrecPatient struct {
MEDRECID string `json:"MEDRECID"` MEDRECID string `json:"MEDRECID"`
Nama *string `json:"Nama"` Nama *string `json:"Nama"`
Kelamin *string `json:"-"`
TanggalLahir *string `json:"TanggalLahir"` TanggalLahir *string `json:"TanggalLahir"`
JenisNomerSosial *string `json:"JenisNomerSosial"` JenisNomerSosial *string `json:"JenisNomerSosial"`
NomerSosial *string `json:"NomerSosial"` NomerSosial *string `json:"NomerSosial"`
@@ -189,7 +190,7 @@ func findPossibleMedrecDuplicates(ctx context.Context, tx *sql.Tx, name string,
} }
const medrecPatientSelect = ` 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 JenisNomerSosial, NomerSosial, Telepon, Ponsel
FROM medrec` FROM medrec`
@@ -199,13 +200,14 @@ type rowScanner interface {
func scanMedrecPatient(row rowScanner) (MedrecPatient, error) { func scanMedrecPatient(row rowScanner) (MedrecPatient, error) {
var patient MedrecPatient var patient MedrecPatient
var name, birthDate, identityType, identityNumber, phone, mobile sql.NullString var name, birthDate, gender, identityType, identityNumber, phone, mobile sql.NullString
err := row.Scan(&patient.MEDRECID, &name, &birthDate, &identityType, &identityNumber, &phone, &mobile) err := row.Scan(&patient.MEDRECID, &name, &birthDate, &gender, &identityType, &identityNumber, &phone, &mobile)
if err != nil { if err != nil {
return MedrecPatient{}, err return MedrecPatient{}, err
} }
patient.Nama = nullStringPtr(name) patient.Nama = nullStringPtr(name)
patient.TanggalLahir = nullStringPtr(birthDate) patient.TanggalLahir = nullStringPtr(birthDate)
patient.Kelamin = nullStringPtr(gender)
patient.JenisNomerSosial = nullStringPtr(identityType) patient.JenisNomerSosial = nullStringPtr(identityType)
patient.NomerSosial = nullStringPtr(identityNumber) patient.NomerSosial = nullStringPtr(identityNumber)
patient.Telepon = nullStringPtr(phone) patient.Telepon = nullStringPtr(phone)

View File

@@ -22,6 +22,47 @@ type PaketListParams struct {
PerPage int 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 { type PaketPage struct {
Items []PaketSummary Items []PaketSummary
Total int Total int
@@ -86,6 +127,7 @@ type PaketPeriodeTarif struct {
type PaketLayanan struct { type PaketLayanan struct {
PaketLayananID int64 `json:"PaketLayananID"` PaketLayananID int64 `json:"PaketLayananID"`
LayananID string `json:"LayananID"` LayananID string `json:"LayananID"`
DepartemenID string `json:"DepartemenID"`
NamaLayanan *string `json:"NamaLayanan"` NamaLayanan *string `json:"NamaLayanan"`
NamaDepartemen *string `json:"NamaDepartemen"` NamaDepartemen *string `json:"NamaDepartemen"`
Jumlah string `json:"Jumlah"` Jumlah string `json:"Jumlah"`
@@ -95,8 +137,11 @@ type PaketLayanan struct {
HargaDasarSource string `json:"HargaDasarSource"` HargaDasarSource string `json:"HargaDasarSource"`
Diskon string `json:"Diskon"` Diskon string `json:"Diskon"`
TipeDiskon string `json:"TipeDiskon"` TipeDiskon string `json:"TipeDiskon"`
TipeShare string `json:"TipeShare"`
ShareRS string `json:"ShareRS"` ShareRS string `json:"ShareRS"`
ShareDokter string `json:"ShareDokter"` ShareDokter string `json:"ShareDokter"`
ShareExternal string `json:"ShareExternal"`
ShareLain string `json:"ShareLain"`
Subtotal string `json:"Subtotal"` Subtotal string `json:"Subtotal"`
} }
@@ -350,10 +395,11 @@ func (r MySQLLayananRepository) paketLayanan(
} }
args = append(args, id) args = append(args, id)
query := fmt.Sprintf(` 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.Jumlah AS CHAR), pl.JumlahHari, CAST(pl.Harga AS CHAR),
CAST(pl.Diskon AS CHAR), pl.TipeDiskon, CAST(pl.ShareRS AS CHAR), CAST(pl.Diskon AS CHAR), pl.TipeDiskon, CAST(pl.ShareRS AS CHAR),
CAST(pl.ShareDokter AS CHAR), CAST(pl.Subtotal 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, ''), %s, COALESCE(ml.DefaultHarga, 0), COALESCE(ml.LayananKode, ''),
CAST(pl.HeaderLayanan AS CHAR) CAST(pl.HeaderLayanan AS CHAR)
FROM paket_layanan pl FROM paket_layanan pl
@@ -378,21 +424,26 @@ func (r MySQLLayananRepository) paketLayanan(
for rows.Next() { for rows.Next() {
var item PaketLayanan var item PaketLayanan
var namaLayanan, namaDepartemen sql.NullString var namaLayanan, namaDepartemen sql.NullString
var harga, shareRS, shareDokter, subtotal string var harga, shareRS, shareDokter, tipeShare, shareExternal, shareLain, subtotal string
var departemenID string
var servicePrice servicePricing var servicePrice servicePricing
if err := rows.Scan( 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, &item.Jumlah, &item.JumlahHari, &harga, &item.Diskon, &item.TipeDiskon,
&shareRS, &shareDokter, &subtotal, &servicePrice.tariffPrice, &shareRS, &shareDokter, &tipeShare, &shareExternal, &shareLain, &subtotal, &servicePrice.tariffPrice,
&servicePrice.defaultPrice, &servicePrice.serviceCode, &servicePrice.operationHeader, &servicePrice.defaultPrice, &servicePrice.serviceCode, &servicePrice.operationHeader,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
item.NamaLayanan = nullStringPtr(namaLayanan) item.NamaLayanan = nullStringPtr(namaLayanan)
item.NamaDepartemen = nullStringPtr(namaDepartemen) item.NamaDepartemen = nullStringPtr(namaDepartemen)
item.DepartemenID = departemenID
item.TipeShare = tipeShare
item.Harga = phpNumberFormat(parseDecimal(harga), 0) item.Harga = phpNumberFormat(parseDecimal(harga), 0)
item.ShareRS = phpNumberFormat(parseDecimal(shareRS), 2) item.ShareRS = phpNumberFormat(parseDecimal(shareRS), 2)
item.ShareDokter = phpNumberFormat(parseDecimal(shareDokter), 0) item.ShareDokter = phpNumberFormat(parseDecimal(shareDokter), 0)
item.ShareExternal = phpNumberFormat(parseDecimal(shareExternal), 2)
item.ShareLain = phpNumberFormat(parseDecimal(shareLain), 2)
item.Subtotal = phpNumberFormat(parseDecimal(subtotal), 0) item.Subtotal = phpNumberFormat(parseDecimal(subtotal), 0)
if servicePrice.tariffPrice.Float64 <= 0 && servicePrice.defaultPrice <= 0 && isAdministrationService(servicePrice.serviceCode) { if servicePrice.tariffPrice.Float64 <= 0 && servicePrice.defaultPrice <= 0 && isAdministrationService(servicePrice.serviceCode) {
needsAdministrationPrice = true needsAdministrationPrice = true

View 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)
}

View 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)
}
}
}

View File

@@ -151,7 +151,7 @@ func (r MySQLLayananRepository) GetPerusahaan(ctx context.Context, id string) (*
func (r MySQLLayananRepository) GetPerusahaanGroupTarif(ctx context.Context, id, groupTarifID string) (*PerusahaanGroupTarifDetail, error) { func (r MySQLLayananRepository) GetPerusahaanGroupTarif(ctx context.Context, id, groupTarifID string) (*PerusahaanGroupTarifDetail, error) {
var result PerusahaanGroupTarifDetail var result PerusahaanGroupTarifDetail
err := r.db.QueryRowContext(ctx, `SELECT PerusahaanID, KodePerusahaan, Nama FROM perusahaan err := r.db.QueryRowContext(ctx, `SELECT PerusahaanID, KodePerusahaan, Nama FROM perusahaan
WHERE NA = 'N' AND (PerusahaanID = ? OR KodePerusahaan = ?) LIMIT 1`, id, id). WHERE PerusahaanID = ? OR KodePerusahaan = ? LIMIT 1`, id, id).
Scan(&result.PerusahaanID, &result.KodePerusahaan, &result.NamaPerusahaan) Scan(&result.PerusahaanID, &result.KodePerusahaan, &result.NamaPerusahaan)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return nil, nil return nil, nil
@@ -171,7 +171,7 @@ func (r MySQLLayananRepository) GetPerusahaanGroupTarif(ctx context.Context, id,
query += " AND pg.GroupTarifID = ?" query += " AND pg.GroupTarifID = ?"
args = append(args, groupTarifID) args = append(args, groupTarifID)
} }
query += " ORDER BY pg.TanggalBerlaku DESC, pg.GroupTarifID DESC" query += " ORDER BY pg.Keterangan"
rows, err := r.db.QueryContext(ctx, query, args...) rows, err := r.db.QueryContext(ctx, query, args...)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -213,7 +213,7 @@ func (r MySQLLayananRepository) perusahaanGroupTarifLayanan(ctx context.Context,
query := fmt.Sprintf(`SELECT tarif.TarifLayananID, tarif.LayananID, layanan.Nama, query := fmt.Sprintf(`SELECT tarif.TarifLayananID, tarif.LayananID, layanan.Nama,
tarif.JenisLayananID, jenis.Nama, tarif.KelasID, kelas.Nama, tarif.JenisLayananID, jenis.Nama, tarif.KelasID, kelas.Nama,
CAST(tarif.Harga AS CHAR), tarif.TipeDiskon, CAST(tarif.Diskon AS CHAR), CAST(tarif.Harga AS CHAR), tarif.TipeDiskon, CAST(tarif.Diskon AS CHAR),
tarif.TipeDiskonRI, CAST(tarif.DiskonRI AS CHAR), tarif.TipeShare, '' AS TipeDiskonRI, '0' AS DiskonRI, tarif.TipeShare,
CAST(tarif.ShareDokter AS CHAR), CAST(tarif.ShareRS AS CHAR), CAST(tarif.ShareDokter AS CHAR), CAST(tarif.ShareRS AS CHAR),
CAST(tarif.ShareExternal AS CHAR), CAST(tarif.ShareLain AS CHAR), tarif.AsalTarif CAST(tarif.ShareExternal AS CHAR), CAST(tarif.ShareLain AS CHAR), tarif.AsalTarif
FROM %s tarif FROM %s tarif

View 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(
&registration.RegID, &registration.MEDRECID, &registration.Tanggal, &registration.DepartmentID,
&registration.DoctorID, &registration.PatientTypeID, &registration.RegistrationID,
&registration.TariffPeriodID, &registration.CompanyID, &registration.InsuranceID,
&registration.ProviderID, &registration.GroupTariffID, &registration.PolicyNumber,
&registration.Pulang, &registration.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 ""
}

View 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)
}
}

View File

@@ -287,8 +287,8 @@ func (r MySQLLayananRepository) tarifKerjasamaLayanan(ctx context.Context, group
query := fmt.Sprintf(`SELECT tarif.TarifLayananID, CAST(tarif.LayananID AS CHAR), query := fmt.Sprintf(`SELECT tarif.TarifLayananID, CAST(tarif.LayananID AS CHAR),
layanan.Nama, CAST(tarif.JenisLayananID AS CHAR), jenis.Nama, layanan.Nama, CAST(tarif.JenisLayananID AS CHAR), jenis.Nama,
CAST(tarif.KelasID AS CHAR), kelas.Nama, CAST(tarif.Harga AS CHAR), CAST(tarif.KelasID AS CHAR), kelas.Nama, CAST(tarif.Harga AS CHAR),
tarif.TipeDiskon, CAST(tarif.Diskon AS CHAR), tarif.TipeDiskonRI, tarif.TipeDiskon, CAST(tarif.Diskon AS CHAR), '' AS TipeDiskonRI,
CAST(tarif.DiskonRI AS CHAR), tarif.TipeShare, CAST(tarif.ShareDokter AS CHAR), '0' AS DiskonRI, tarif.TipeShare, CAST(tarif.ShareDokter AS CHAR),
CAST(tarif.ShareRS AS CHAR), CAST(tarif.ShareExternal AS CHAR), CAST(tarif.ShareRS AS CHAR), CAST(tarif.ShareExternal AS CHAR),
CAST(tarif.ShareLain AS CHAR), tarif.AsalTarif CAST(tarif.ShareLain AS CHAR), tarif.AsalTarif
FROM %s tarif FROM %s tarif

View 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
View 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"