feat: add package creation and clone validation

This commit is contained in:
2026-09-16 13:55:56 +07:00
parent 94c3dd1a70
commit 19fe8c5d94
12 changed files with 1034 additions and 10 deletions

View File

@@ -7,6 +7,10 @@ GET /api/cpone/paket
GET /api/cpone/paket/{id}
Authorization: Bearer <your-secure-token>
POST /api/cpone/paket
Authorization: Bearer <your-secure-token>
Content-Type: application/json
GET /api/cpone/layanan?page=1
Authorization: Bearer <your-secure-token>
X-RS-Code: <kode-rs>
@@ -68,6 +72,56 @@ Content-Type: application/json
Format payload dan response dibuat mengikuti controller PHP CPONE terkait.
Contoh payload pembuatan paket beserta layanan:
```json
{
"nama": "PAKET MCU CPONE TES 2",
"jenis_paket": "MCU",
"rawat_inap": false,
"rawat_jalan": true,
"multi_visit": false,
"tanggal_mulai": "2026-09-01",
"tanggal_selesai": "2026-09-30",
"kelas_id": "Default",
"header_paket_id": "",
"keterangan": "",
"layanan": [
{
"layanan_id": "1200",
"departemen_id": "MED-PNJ-LAB",
"jumlah": 1,
"jumlah_hari": 1,
"harga": "130000.00",
"tipe_diskon": "Percentage",
"diskon": "0.00",
"tipe_share": "Absolute",
"share_rs": "65000.00",
"share_dokter": "0.00",
"share_external": "65000.00",
"share_lain": "0.00"
}
]
}
```
Endpoint mengembalikan HTTP 201 dengan format standar middleware:
```json
{
"success": true,
"status": "created",
"message": "Paket berhasil dibuat",
"data": {
"paket_id": "DISP-260900022",
"nama": "PAKET MCU CPONE TES 2",
"total_harga": "130000.00",
"total_diskon": "0.00",
"jumlah_layanan": 1
}
}
```
Contoh payload pembuatan pasien HIS:
```json

View File

@@ -41,6 +41,7 @@ func main() {
}
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/{id}", protected(http.HandlerFunc(handler.GetPaket)))
mux.Handle("GET /api/cpone/layanan", protected(http.HandlerFunc(handler.ListLayanan)))

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

@@ -38,6 +38,7 @@ type LabResultRepository interface {
type PaketRepository interface {
ListPaket(ctx context.Context, params repository.PaketListParams) (repository.PaketPage, error)
GetPaket(ctx context.Context, id string) (*repository.PaketDetail, error)
CreatePaket(ctx context.Context, params repository.CreatePaketParams) (repository.CreatePaketResult, error)
}
type TarifKerjasamaRepository interface {

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

View File

@@ -22,6 +22,47 @@ type PaketListParams struct {
PerPage int
}
// CreatePaketParams contains values already validated by the HTTP layer.
// Monetary values are stored as cents to keep calculations exact without float.
type CreatePaketParams struct {
Nama string
JenisPaket string
RawatInap bool
RawatJalan bool
MultiVisit bool
TanggalMulai string
TanggalSelesai string
KelasID string
HeaderPaketID string
Keterangan string
JumlahHari int64
Channel string
Layanan []CreatePaketLayananParams
}
type CreatePaketLayananParams struct {
LayananID string
DepartemenID string
Jumlah int64
JumlahHari int64
HargaCents int64
TipeDiskon string
DiskonCents int64
TipeShare string
ShareRSCents int64
ShareDokterCents int64
ShareExternalCents int64
ShareLainCents int64
}
type CreatePaketResult struct {
PaketID string
Nama string
TotalHarga string
TotalDiskon string
JumlahLayanan int
}
type PaketPage struct {
Items []PaketSummary
Total int
@@ -86,6 +127,7 @@ type PaketPeriodeTarif struct {
type PaketLayanan struct {
PaketLayananID int64 `json:"PaketLayananID"`
LayananID string `json:"LayananID"`
DepartemenID string `json:"DepartemenID"`
NamaLayanan *string `json:"NamaLayanan"`
NamaDepartemen *string `json:"NamaDepartemen"`
Jumlah string `json:"Jumlah"`
@@ -95,8 +137,11 @@ type PaketLayanan struct {
HargaDasarSource string `json:"HargaDasarSource"`
Diskon string `json:"Diskon"`
TipeDiskon string `json:"TipeDiskon"`
TipeShare string `json:"TipeShare"`
ShareRS string `json:"ShareRS"`
ShareDokter string `json:"ShareDokter"`
ShareExternal string `json:"ShareExternal"`
ShareLain string `json:"ShareLain"`
Subtotal string `json:"Subtotal"`
}
@@ -350,10 +395,11 @@ func (r MySQLLayananRepository) paketLayanan(
}
args = append(args, id)
query := fmt.Sprintf(`
SELECT pl.PaketLayananID, pl.LayananID, ml.Nama, d.Nama,
SELECT pl.PaketLayananID, pl.LayananID, pl.DepartemenID, ml.Nama, d.Nama,
CAST(pl.Jumlah AS CHAR), pl.JumlahHari, CAST(pl.Harga AS CHAR),
CAST(pl.Diskon AS CHAR), pl.TipeDiskon, CAST(pl.ShareRS AS CHAR),
CAST(pl.ShareDokter AS CHAR), CAST(pl.Subtotal AS CHAR),
CAST(pl.ShareDokter AS CHAR), pl.ShareTipe, CAST(pl.ShareExternal AS CHAR),
CAST(pl.ShareLain AS CHAR), CAST(pl.Subtotal AS CHAR),
%s, COALESCE(ml.DefaultHarga, 0), COALESCE(ml.LayananKode, ''),
CAST(pl.HeaderLayanan AS CHAR)
FROM paket_layanan pl
@@ -378,21 +424,26 @@ func (r MySQLLayananRepository) paketLayanan(
for rows.Next() {
var item PaketLayanan
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
if err := rows.Scan(
&item.PaketLayananID, &item.LayananID, &namaLayanan, &namaDepartemen,
&item.PaketLayananID, &item.LayananID, &departemenID, &namaLayanan, &namaDepartemen,
&item.Jumlah, &item.JumlahHari, &harga, &item.Diskon, &item.TipeDiskon,
&shareRS, &shareDokter, &subtotal, &servicePrice.tariffPrice,
&shareRS, &shareDokter, &tipeShare, &shareExternal, &shareLain, &subtotal, &servicePrice.tariffPrice,
&servicePrice.defaultPrice, &servicePrice.serviceCode, &servicePrice.operationHeader,
); err != nil {
return nil, err
}
item.NamaLayanan = nullStringPtr(namaLayanan)
item.NamaDepartemen = nullStringPtr(namaDepartemen)
item.DepartemenID = departemenID
item.TipeShare = tipeShare
item.Harga = phpNumberFormat(parseDecimal(harga), 0)
item.ShareRS = phpNumberFormat(parseDecimal(shareRS), 2)
item.ShareDokter = phpNumberFormat(parseDecimal(shareDokter), 0)
item.ShareExternal = phpNumberFormat(parseDecimal(shareExternal), 2)
item.ShareLain = phpNumberFormat(parseDecimal(shareLain), 2)
item.Subtotal = phpNumberFormat(parseDecimal(subtotal), 0)
if servicePrice.tariffPrice.Float64 <= 0 && servicePrice.defaultPrice <= 0 && isAdministrationService(servicePrice.serviceCode) {
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, '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

@@ -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.