Add MCU report endpoint and deployment tooling

This commit is contained in:
2026-09-25 06:49:58 +07:00
parent d881872206
commit dd62728316
14 changed files with 3841 additions and 2 deletions

View File

@@ -1,5 +1,5 @@
APP_HOST=0.0.0.0 APP_HOST=0.0.0.0
APP_PORT=8080 APP_PORT=8282
# Kode instance rumah sakit. Dipakai hanya untuk validasi X-RS-Code. # Kode instance rumah sakit. Dipakai hanya untuk validasi X-RS-Code.
CPONE_RS_CODE=AWALBROS_DEV CPONE_RS_CODE=AWALBROS_DEV

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

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
@@ -53,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
@@ -72,6 +118,18 @@ 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: Contoh payload pembuatan paket beserta layanan:
```json ```json

View File

@@ -62,6 +62,12 @@ func main() {
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

@@ -64,6 +64,10 @@ type DokterRepository interface {
ListDokter(ctx context.Context, params repository.DokterListParams) (repository.DokterPage, error) 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
@@ -76,6 +80,7 @@ type Handler struct {
periodeTarifDetailRepo PeriodeTarifDetailRepository periodeTarifDetailRepo PeriodeTarifDetailRepository
perusahaanRepo PerusahaanRepository perusahaanRepo PerusahaanRepository
dokterRepo DokterRepository dokterRepo DokterRepository
laporanMCURepo LaporanMCURepository
hospitalCode string hospitalCode string
departmentID string departmentID string
} }
@@ -95,12 +100,19 @@ type CPONERepository interface {
} }
func NewHandler(repo CPONERepository) Handler { func NewHandler(repo CPONERepository) Handler {
return Handler{ handler := Handler{
layananRepo: repo, labRepo: repo, patientRepo: repo, registrationRepo: 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, dokterRepo: repo,
} }
// Keep NewHandler source-compatible with repositories created before the
// MCU report was added. The concrete MySQL repository implements this
// optional capability and the dynamic production handler uses it.
if reportRepo, ok := any(repo).(LaporanMCURepository); ok {
handler.laporanMCURepo = reportRepo
}
return handler
} }
func NewDynamicHandler(hospitalCode string, departmentID string) Handler { func NewDynamicHandler(hospitalCode string, departmentID string) Handler {
@@ -187,6 +199,13 @@ func (h Handler) dokterRepository(ctx context.Context) DokterRepository {
return h.dokterRepo 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

@@ -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,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]
}

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"