Compare commits

...

8 Commits

18 changed files with 1130 additions and 91 deletions

View File

@@ -24,6 +24,10 @@ POST /api/cpone/patients/lab-registration/{regId}/services
Authorization: Bearer <your-secure-token>
Content-Type: application/json
POST /api/cpone/patients/lab-registration/{regId}/packages
Authorization: Bearer <your-secure-token>
Content-Type: application/json
POST /api/cpone/patients/medrec
Authorization: Bearer <your-secure-token>
Content-Type: application/json
@@ -51,6 +55,10 @@ Contoh payload pembuatan pasien HIS:
"kecamatan": "KECAMATAN CONTOH",
"kelurahan": "KELURAHAN CONTOH",
"kode_pos": "12345",
"perusahaan_id": "PE260700001",
"nik_karyawan": "TSB2026002",
"dependant_id": "D001",
"nama_karyawan": "BUDI SANTOS",
"channel": "CPONE"
}
```
@@ -103,6 +111,22 @@ Harga, diskon publik, share, unit cost, layanan multiple, dan spesimen diambil
langsung dari master HIS. Layanan yang sudah ada akan dilewati dan dilaporkan
melalui `skipped_layanan_ids`.
Contoh payload penambahan Paket Dispenser HIS:
```json
{
"paket_id": "DISP-260800011",
"cito": false,
"markup_cito": 0,
"catatan": "",
"channel": "CPONE"
}
```
Paket harus aktif, disetujui, berlaku untuk rawat jalan, dan belum memiliki
layanan yang sama pada registrasi. Request ulang untuk paket yang sudah masuk
bersifat idempoten dan tidak membuat transaksi ganda.
## Config
Copy `.env.example` menjadi `.env`, lalu isi koneksi MySQL:

View File

@@ -52,6 +52,7 @@ func main() {
mux.Handle("POST /api/cpone/patients/medrec", apphttp.BearerAuth(cfg.BearerToken, http.HandlerFunc(handler.StoreMedrec)))
mux.Handle("POST /api/cpone/patients/lab-registration", apphttp.BearerAuth(cfg.BearerToken, http.HandlerFunc(handler.RegisterLab)))
mux.Handle("POST /api/cpone/patients/lab-registration/{regId}/services", apphttp.BearerAuth(cfg.BearerToken, http.HandlerFunc(handler.AddLabServices)))
mux.Handle("POST /api/cpone/patients/lab-registration/{regId}/packages", apphttp.BearerAuth(cfg.BearerToken, http.HandlerFunc(handler.AddLabPackage)))
mux.Handle("GET /api/cpone/patients/lab-results/{labNumber}", apphttp.BearerAuth(cfg.BearerToken, http.HandlerFunc(handler.GetLabResult)))
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")

View File

@@ -19,6 +19,7 @@ type LayananRepository interface {
type LabRegistrationRepository interface {
RegisterLab(ctx context.Context, params repository.LabRegistrationParams) (repository.LabRegistrationOutcome, error)
AddLabServices(ctx context.Context, regID string, params repository.AddLabServicesParams) (repository.LabRegistrationOutcome, error)
AddLabPackage(ctx context.Context, regID string, params repository.AddLabPackageParams) (repository.LabRegistrationOutcome, error)
}
type PatientRepository interface {

View File

@@ -0,0 +1,84 @@
package http
import (
"net/http"
"regexp"
"strings"
"unicode/utf8"
"primaya-api/cpone-middleware/internal/repository"
)
var labIdentifier = regexp.MustCompile(`^[A-Za-z0-9-]+$`)
func (h Handler) AddLabPackage(w http.ResponseWriter, r *http.Request) {
regID := strings.TrimSpace(r.PathValue("regId"))
if regID == "" || utf8.RuneCountInString(regID) > 50 || !labIdentifier.MatchString(regID) {
writeLabResponse(w, http.StatusUnprocessableEntity, false, "validation_error", "Nomor registrasi laboratorium hanya boleh berisi huruf, angka, dan tanda hubung", nil)
return
}
payload, _ := decodeJSONObject(r)
params, message := validateAddLabPackage(payload)
if message != "" {
writeLabResponse(w, http.StatusUnprocessableEntity, false, "validation_error", message, nil)
return
}
outcome, err := h.labRepo.AddLabPackage(r.Context(), strings.ToUpper(regID), params)
if err != nil {
if validationMessage, ok := asValidationError(err); ok {
writeLabResponse(w, http.StatusUnprocessableEntity, false, "validation_error", validationMessage, nil)
return
}
writeLabResponse(w, http.StatusInternalServerError, false, "error", err.Error(), nil)
return
}
writeLabResponse(w, outcome.HTTPStatus, outcome.Success, outcome.Status, outcome.Message, outcome.Data)
}
func validateAddLabPackage(payload map[string]any) (repository.AddLabPackageParams, string) {
packageID, message := requiredString(payload, "paket_id", 50, "Paket ID wajib diisi")
if message != "" {
return repository.AddLabPackageParams{}, message
}
packageID = strings.ToUpper(strings.TrimSpace(packageID))
if !labIdentifier.MatchString(packageID) {
return repository.AddLabPackageParams{}, "Paket ID hanya boleh berisi huruf, angka, dan tanda hubung"
}
cito := false
if value, present := nullableValue(payload, "cito"); present {
var valid bool
cito, valid = parseLaravelBoolean(value)
if !valid {
return repository.AddLabPackageParams{}, "The cito field must be true or false."
}
}
markup := 0.0
if value, present := nullableValue(payload, "markup_cito"); present {
var valid bool
markup, valid = parseNumeric(value)
if !valid {
return repository.AddLabPackageParams{}, "The markup cito must be a number."
}
if markup < 0 || markup > 100 {
return repository.AddLabPackageParams{}, "The markup cito must be between 0 and 100."
}
}
if !cito {
markup = 0
}
notes, _, message := optionalString(payload, "catatan", 500)
if message != "" {
return repository.AddLabPackageParams{}, message
}
channel, present, message := optionalString(payload, "channel", 50)
if message != "" {
return repository.AddLabPackageParams{}, message
}
if !present {
channel = "CPONE"
}
return repository.AddLabPackageParams{
PackageID: packageID, Cito: cito, MarkupCito: markup,
Notes: strings.TrimSpace(notes), Channel: strings.ToUpper(strings.TrimSpace(channel)),
}, ""
}

View File

@@ -0,0 +1,50 @@
package http
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"primaya-api/cpone-middleware/internal/repository"
)
func TestAddLabPackageValidPayload(t *testing.T) {
repo := &fakeLabRegistrationRepository{outcome: repository.LabRegistrationOutcome{
HTTPStatus: 201, Success: true, Status: "package_added",
Message: "Paket dispenser berhasil ditambahkan ke registrasi laboratorium",
}}
handler := Handler{labRepo: repo}
request := httptest.NewRequest(http.MethodPost, "/packages", strings.NewReader(`{"paket_id":"disp-260800011","cito":true,"markup_cito":"10","catatan":" test ","channel":"cpone"}`))
request.SetPathValue("regId", "lab-2608210003")
recorder := httptest.NewRecorder()
handler.AddLabPackage(recorder, request)
if recorder.Code != http.StatusCreated {
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
}
if repo.regID != "LAB-2608210003" || repo.packageParams.PackageID != "DISP-260800011" || !repo.packageParams.Cito || repo.packageParams.MarkupCito != 10 || repo.packageParams.Notes != "test" || repo.packageParams.Channel != "CPONE" {
t.Fatalf("unexpected repository call: reg=%q params=%+v", repo.regID, repo.packageParams)
}
}
func TestAddLabPackageValidation(t *testing.T) {
tests := []struct{ name, regID, body, message string }{
{"invalid registration", "LAB/1", `{"paket_id":"DISP-1"}`, "Nomor registrasi laboratorium hanya boleh berisi huruf, angka, dan tanda hubung"},
{"missing package", "LAB-1", `{}`, "Paket ID wajib diisi"},
{"invalid package", "LAB-1", `{"paket_id":"DISP/1"}`, "Paket ID hanya boleh berisi huruf, angka, dan tanda hubung"},
{"invalid cito", "LAB-1", `{"paket_id":"DISP-1","cito":"true"}`, "The cito field must be true or false."},
{"invalid markup", "LAB-1", `{"paket_id":"DISP-1","markup_cito":101}`, "The markup cito must be between 0 and 100."},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
handler := Handler{labRepo: &fakeLabRegistrationRepository{}}
request := httptest.NewRequest(http.MethodPost, "/packages", strings.NewReader(test.body))
request.SetPathValue("regId", test.regID)
recorder := httptest.NewRecorder()
handler.AddLabPackage(recorder, request)
if recorder.Code != 422 || !strings.Contains(recorder.Body.String(), test.message) {
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
}
})
}
}

View File

@@ -115,13 +115,11 @@ func validateLabRegistration(payload map[string]any) (validatedLabRegistration,
return validatedLabRegistration{}, "Dokter pengirim atau perujuk wajib diisi"
}
patientType, patientTypePresent, message := optionalString(payload, "jenis_pasien_id", 0)
patientType, message := requiredString(payload, "jenis_pasien_id", 20, "Jenis pasien wajib diisi")
if message != "" {
return validatedLabRegistration{}, message
}
if patientTypePresent && patientType != "0" {
return validatedLabRegistration{}, "Registrasi laboratorium API saat ini hanya mendukung pasien umum"
}
patientType = strings.TrimSpace(patientType)
tariffPeriodID, message := requiredInteger(payload, "periode_tarif_id", "Periode tarif wajib diisi")
if message != "" {
@@ -131,6 +129,60 @@ func validateLabRegistration(payload map[string]any) (validatedLabRegistration,
return validatedLabRegistration{}, "The periode tarif id must be at least 1."
}
companyID, companyPresent, message := optionalString(payload, "perusahaan_id", 50)
if message != "" {
return validatedLabRegistration{}, message
}
insuranceID, insurancePresent, message := optionalString(payload, "asuransi_id", 50)
if message != "" {
return validatedLabRegistration{}, message
}
providerID, _, message := optionalString(payload, "provider_id", 50)
if message != "" {
return validatedLabRegistration{}, message
}
policyNumber, _, message := optionalString(payload, "nomer_polis", 100)
if message != "" {
return validatedLabRegistration{}, message
}
employeeID, _, message := optionalString(payload, "nik_karyawan", 100)
if message != "" {
return validatedLabRegistration{}, message
}
employeeName, _, message := optionalString(payload, "nama_karyawan", 255)
if message != "" {
return validatedLabRegistration{}, message
}
var groupTariffID int64
if value, present := nullableValue(payload, "group_tarif_id"); present {
parsed, ok := parseInteger(value)
if !ok {
return validatedLabRegistration{}, "The group tarif id must be an integer."
}
if parsed < 1 && patientType != "0" {
return validatedLabRegistration{}, "The group tarif id must be at least 1."
}
groupTariffID = parsed
}
if patientType != "0" {
if !companyPresent || strings.TrimSpace(companyID) == "" {
return validatedLabRegistration{}, "Perusahaan wajib diisi untuk pasien penjamin"
}
if !insurancePresent || strings.TrimSpace(insuranceID) == "" {
return validatedLabRegistration{}, "Asuransi/penanggung wajib diisi untuk pasien penjamin"
}
if groupTariffID < 1 {
return validatedLabRegistration{}, "Group tarif wajib diisi untuk pasien penjamin"
}
} else {
companyID = ""
insuranceID = ""
providerID = ""
groupTariffID = 0
}
classID, classPresent, message := optionalString(payload, "kelas_id", 50)
if message != "" {
return validatedLabRegistration{}, message
@@ -184,7 +236,15 @@ func validateLabRegistration(payload map[string]any) (validatedLabRegistration,
DoctorID: doctorID,
SenderDoctorID: senderDoctorID,
Referrer: referrer,
PatientType: patientType,
TariffPeriodID: tariffPeriodID,
CompanyID: strings.TrimSpace(companyID),
InsuranceID: strings.TrimSpace(insuranceID),
ProviderID: strings.TrimSpace(providerID),
GroupTariffID: groupTariffID,
PolicyNumber: strings.TrimSpace(policyNumber),
EmployeeID: strings.TrimSpace(employeeID),
EmployeeName: strings.TrimSpace(employeeName),
ClassID: classID,
DailyShiftID: dailyShiftID,
WorkingDiagnosis: workingDiagnosis,

View File

@@ -13,11 +13,12 @@ import (
)
type fakeLabRegistrationRepository struct {
outcome repository.LabRegistrationOutcome
err error
params repository.LabRegistrationParams
addParams repository.AddLabServicesParams
regID string
outcome repository.LabRegistrationOutcome
err error
params repository.LabRegistrationParams
addParams repository.AddLabServicesParams
packageParams repository.AddLabPackageParams
regID string
}
func (f *fakeLabRegistrationRepository) RegisterLab(_ context.Context, params repository.LabRegistrationParams) (repository.LabRegistrationOutcome, error) {
@@ -31,6 +32,12 @@ func (f *fakeLabRegistrationRepository) AddLabServices(_ context.Context, regID
return f.outcome, f.err
}
func (f *fakeLabRegistrationRepository) AddLabPackage(_ context.Context, regID string, params repository.AddLabPackageParams) (repository.LabRegistrationOutcome, error) {
f.regID = regID
f.packageParams = params
return f.outcome, f.err
}
func TestRegisterLabValidPayloadAndResponseParity(t *testing.T) {
doctorID := "DR01"
registrationType := int64(3)
@@ -80,7 +87,7 @@ func TestRegisterLabValidPayloadAndResponseParity(t *testing.T) {
if recorder.Code != http.StatusCreated {
t.Fatalf("status = %d, want %d; body=%s", recorder.Code, http.StatusCreated, recorder.Body.String())
}
const expected = `{"success":true,"status":"registered","message":"Registrasi laboratorium berhasil disimpan","data":{"registration":{"RegID":"CPLAB2608180001","MEDRECID":"00001234","Tanggal":"2026-08-18 10:11:12","DepartemenID":"LAB","DokterID":"DR01","JenisPasienID":0,"JenisRegID":3,"PeriodeTarifID":10},"transaction":{"TrxDepartemenID":99,"TrxID":"CPLAB2608180001","GrandTotal":0},"layanan":[]}}` + "\n"
const expected = `{"success":true,"status":"registered","message":"Registrasi laboratorium berhasil disimpan","data":{"registration":{"RegID":"CPLAB2608180001","MEDRECID":"00001234","Tanggal":"2026-08-18 10:11:12","DepartemenID":"LAB","DokterID":"DR01","JenisPasienID":0,"JenisRegID":3,"PeriodeTarifID":10,"PerusahaanID":null,"AsuransiID":null,"ProviderID":null,"GroupTarifID":null,"NomerPolis":null},"transaction":{"TrxDepartemenID":99,"TrxID":"CPLAB2608180001","GrandTotal":0},"layanan":[]}}` + "\n"
if recorder.Body.String() != expected {
t.Fatalf("body mismatch\n got: %s\nwant: %s", recorder.Body.String(), expected)
}
@@ -96,12 +103,57 @@ func TestRegisterLabValidPayloadAndResponseParity(t *testing.T) {
}
}
func TestRegisterLabCompanyPayload(t *testing.T) {
repo := &fakeLabRegistrationRepository{outcome: repository.LabRegistrationOutcome{
HTTPStatus: http.StatusCreated,
Success: true,
Status: "registered",
Message: "Registrasi laboratorium berhasil disimpan",
}}
handler := Handler{labRepo: repo}
body := `{
"medrec_id":"00409575",
"departemen_id":"MED-PNJ-LAB",
"dokter_id":"SPP01_003",
"dokter_pengirim_id":"Luar",
"perujuk":"DOKTER PERUJUK LUAR",
"jenis_pasien_id":"1",
"periode_tarif_id":7,
"perusahaan_id":"PE260700001",
"asuransi_id":"PE260700001",
"provider_id":"",
"group_tarif_id":1980,
"nomer_polis":"POLIS-001",
"nik_karyawan":"TSB2026002",
"nama_karyawan":"INTAN SEPTIANI",
"kelas_id":"Default",
"shift_harian_id":48049,
"diagnosa_kerja":"Test diagnosa"
}`
request := httptest.NewRequest(http.MethodPost, "/api/cpone/patients/lab-registration", strings.NewReader(body))
recorder := httptest.NewRecorder()
handler.RegisterLab(recorder, request)
if recorder.Code != http.StatusCreated {
t.Fatalf("status = %d, want %d; body=%s", recorder.Code, http.StatusCreated, recorder.Body.String())
}
params := repo.params
if params.PatientType != "1" || params.CompanyID != "PE260700001" || params.InsuranceID != "PE260700001" || params.GroupTariffID != 1980 {
t.Fatalf("company guarantor params mismatch: %+v", params)
}
if params.PolicyNumber != "POLIS-001" || params.EmployeeID != "TSB2026002" || params.EmployeeName != "INTAN SEPTIANI" {
t.Fatalf("company employee params mismatch: %+v", params)
}
}
func TestRegisterLabValidationParity(t *testing.T) {
baseline := map[string]any{
"medrec_id": "00001234",
"departemen_id": "LAB",
"dokter_id": "DR01",
"dokter_pengirim_id": "DR02",
"jenis_pasien_id": "0",
"periode_tarif_id": 10,
"diagnosa_kerja": "Pemeriksaan laboratorium",
}
@@ -130,9 +182,14 @@ func TestRegisterLabValidationParity(t *testing.T) {
message: "Dokter pengirim atau perujuk wajib diisi",
},
{
name: "non-general patient",
name: "missing company for guarantor",
mutate: func(payload map[string]any) { payload["jenis_pasien_id"] = "1" },
message: "Registrasi laboratorium API saat ini hanya mendukung pasien umum",
message: "Perusahaan wajib diisi untuk pasien penjamin",
},
{
name: "missing patient type",
mutate: func(payload map[string]any) { delete(payload, "jenis_pasien_id") },
message: "Jenis pasien wajib diisi",
},
{
name: "invalid tariff period type",
@@ -203,7 +260,7 @@ func TestRegisterLabRepositoryErrorsMatchLaravelEnvelope(t *testing.T) {
},
}
const validBody = `{"medrec_id":"00001234","departemen_id":"LAB","dokter_id":"DR01","dokter_pengirim_id":"DR02","periode_tarif_id":10,"diagnosa_kerja":"Pemeriksaan laboratorium"}`
const validBody = `{"medrec_id":"00001234","departemen_id":"LAB","dokter_id":"DR01","dokter_pengirim_id":"DR02","jenis_pasien_id":"0","periode_tarif_id":10,"diagnosa_kerja":"Pemeriksaan laboratorium"}`
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
handler := Handler{labRepo: &fakeLabRegistrationRepository{err: test.err}}

View File

@@ -58,7 +58,7 @@ func TestAddLabServicesValidPayloadAndResponseParity(t *testing.T) {
if recorder.Code != http.StatusCreated {
t.Fatalf("status = %d; body=%s", recorder.Code, recorder.Body.String())
}
const expected = `{"success":true,"status":"services_added","message":"Layanan laboratorium berhasil ditambahkan","data":{"registration":{"RegID":"CPLAB2608180001","MEDRECID":"00001234","Tanggal":"2026-08-18 10:11:12","DepartemenID":"LAB","DokterID":"DR01","JenisPasienID":0,"JenisRegID":3,"PeriodeTarifID":10},"transaction":{"TrxDepartemenID":99,"TrxID":"CPLAB2608180001","GrandTotal":125000},"layanan":[],"added_layanan_ids":["6413"],"skipped_layanan_ids":["6414"]}}` + "\n"
const expected = `{"success":true,"status":"services_added","message":"Layanan laboratorium berhasil ditambahkan","data":{"registration":{"RegID":"CPLAB2608180001","MEDRECID":"00001234","Tanggal":"2026-08-18 10:11:12","DepartemenID":"LAB","DokterID":"DR01","JenisPasienID":0,"JenisRegID":3,"PeriodeTarifID":10,"PerusahaanID":null,"AsuransiID":null,"ProviderID":null,"GroupTarifID":null,"NomerPolis":null},"transaction":{"TrxDepartemenID":99,"TrxID":"CPLAB2608180001","GrandTotal":125000},"layanan":[],"added_layanan_ids":["6413"],"skipped_layanan_ids":["6414"]}}` + "\n"
if recorder.Body.String() != expected {
t.Fatalf("body mismatch\n got: %s\nwant: %s", recorder.Body.String(), expected)
}

View File

@@ -155,6 +155,18 @@ func validateMedrec(payload map[string]any) (repository.MedrecParams, string) {
if message != "" {
return repository.MedrecParams{}, message
}
employeeID, message := optionalMedrecString(payload, "nik_karyawan", 50)
if message != "" {
return repository.MedrecParams{}, message
}
dependantID, message := optionalMedrecString(payload, "dependant_id", 4)
if message != "" {
return repository.MedrecParams{}, message
}
employeeName, message := optionalMedrecString(payload, "nama_karyawan", 100)
if message != "" {
return repository.MedrecParams{}, message
}
channel, channelPresent, message := optionalString(payload, "channel", 50)
if message != "" {
return repository.MedrecParams{}, message
@@ -196,6 +208,9 @@ func validateMedrec(payload map[string]any) (repository.MedrecParams, string) {
Village: strings.TrimSpace(village),
PostalCode: strings.TrimSpace(postalCode),
CompanyID: strings.TrimSpace(companyID),
EmployeeID: strings.TrimSpace(employeeID),
DependantID: strings.TrimSpace(dependantID),
EmployeeName: strings.TrimSpace(employeeName),
Channel: normalizeText(channel),
}, ""
}

View File

@@ -66,6 +66,10 @@ func TestStoreMedrecValidPayloadAndCreatedResponseParity(t *testing.T) {
"kecamatan":" KECAMATAN CONTOH ",
"kelurahan":" KELURAHAN CONTOH ",
"kode_pos":" 12345 ",
"perusahaan_id":" PE260700001 ",
"nik_karyawan":" TSB2026002 ",
"dependant_id":"D001",
"nama_karyawan":" Budi Santos ",
"channel":" cpone "
}`
@@ -93,6 +97,9 @@ func TestStoreMedrecValidPayloadAndCreatedResponseParity(t *testing.T) {
if repo.params.Email != "budi@example.com" || repo.params.PlaceOfBirth != "JAKARTA" || repo.params.Channel != "CPONE" {
t.Fatalf("text normalization mismatch: %+v", repo.params)
}
if repo.params.CompanyID != "PE260700001" || repo.params.EmployeeID != "TSB2026002" || repo.params.DependantID != "D001" || repo.params.EmployeeName != "Budi Santos" {
t.Fatalf("company patient fields mismatch: %+v", repo.params)
}
if repo.params.DateOfBirth.Format("2006-01-02") != "1990-01-15" {
t.Fatalf("birth date mismatch: %s", repo.params.DateOfBirth)
}
@@ -125,6 +132,7 @@ func TestStoreMedrecValidationParity(t *testing.T) {
{name: "missing phone", mutate: func(p map[string]any) { delete(p, "telepon") }, message: "Telepon atau ponsel wajib diisi"},
{name: "invalid email", mutate: func(p map[string]any) { p["email"] = "invalid" }, message: "The email must be a valid email address."},
{name: "long hospital code", mutate: func(p map[string]any) { p["rs"] = strings.Repeat("X", 21) }, message: "The rs must not be greater than 20 characters."},
{name: "long dependant id", mutate: func(p map[string]any) { p["dependant_id"] = "D0001" }, message: "The dependant id must not be greater than 4 characters."},
}
for _, test := range tests {

View File

@@ -92,13 +92,18 @@ func TestListPaketValidationParity(t *testing.T) {
}
func TestGetPaketResponseAndNotFoundParity(t *testing.T) {
keteranganPeriode := "Tarif Dasar 2026"
detail := &repository.PaketDetail{
PaketID: "DISP-260800002", Nama: "Test paket", JenisPaket: "DIA",
RI: 1, TglDari: "2026-08-06", TglSampai: "2026-08-31", KelasID: "KelasII",
JumlahHari: 1, TotalHarga: "1550913.00", StatusID: 2, LoginBuat: "API_CRM",
TanggalBuat: "2026-08-05T10:05:03.000000Z", LoginEdit: "API_CRM",
TanggalEdit: "2026-08-19T06:05:20.000000Z", NA: "N",
Layanan: []repository.PaketLayanan{{HargaDasar: "100,000"}},
PeriodeTarif: &repository.PaketPeriodeTarif{
PeriodeTarifID: 42, Keterangan: &keteranganPeriode,
TanggalBerlaku: "2026-01-01", TanggalBerakhir: "2026-12-31",
},
Layanan: []repository.PaketLayanan{{HargaDasar: "100,000", HargaDasarSource: "TARIF_PERIODE"}},
Operasi: []repository.PaketOperasi{{HargaDasar: "250,000.00"}},
Item: []repository.PaketItem{{HargaDasar: "50,000.00"}},
Kamar: []repository.PaketKamar{{HargaDasar: "200,000.00"}},
@@ -113,7 +118,9 @@ func TestGetPaketResponseAndNotFoundParity(t *testing.T) {
t.Fatalf("status=%d id=%q body=%s", recorder.Code, repo.detailID, recorder.Body.String())
}
for _, expected := range []string{
`"PeriodeTarif":{"PeriodeTarifID":42,"Keterangan":"Tarif Dasar 2026","TanggalBerlaku":"2026-01-01","TanggalBerakhir":"2026-12-31"}`,
`"HargaDasar":"100,000"`,
`"HargaDasarSource":"TARIF_PERIODE"`,
`"HargaDasar":"250,000.00"`,
`"HargaDasar":"50,000.00"`,
`"HargaDasar":"200,000.00"`,

View File

@@ -0,0 +1,374 @@
package repository
import (
"context"
"database/sql"
"errors"
"fmt"
"math"
"strings"
"time"
)
type AddLabPackageParams struct {
PackageID string
Cito bool
MarkupCito float64
Notes string
Channel string
}
type labPackageHeader struct {
ID string
Name string
Days sql.NullInt64
TotalPrice sql.NullString
}
type labPackageService struct {
ID int64
DepartmentID string
ServiceID string
HeaderService sql.NullString
Quantity sql.NullString
Days sql.NullInt64
Price sql.NullString
DiscountType sql.NullString
Discount sql.NullString
ShareType sql.NullString
ShareHospital sql.NullString
ShareDoctor sql.NullString
ShareExternal sql.NullString
ShareOther sql.NullString
}
func (r MySQLLayananRepository) AddLabPackage(ctx context.Context, regID string, params AddLabPackageParams) (LabRegistrationOutcome, error) {
var lastErr error
for attempt := 0; attempt < 3; attempt++ {
outcome, err := r.addLabPackageAttempt(ctx, regID, params)
if err == nil {
return outcome, nil
}
lastErr = err
if !isConcurrencyError(err) || ctx.Err() != nil {
return LabRegistrationOutcome{}, err
}
}
return LabRegistrationOutcome{}, lastErr
}
func (r MySQLLayananRepository) addLabPackageAttempt(ctx context.Context, regID string, params AddLabPackageParams) (outcome LabRegistrationOutcome, err error) {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return LabRegistrationOutcome{}, err
}
defer func() {
if recovered := recover(); recovered != nil {
_ = tx.Rollback()
panic(recovered)
}
if err != nil {
_ = tx.Rollback()
}
}()
outcome, err = addLabPackageInTransaction(ctx, tx, strings.ToUpper(strings.TrimSpace(regID)), params)
if err != nil {
return LabRegistrationOutcome{}, err
}
if err = tx.Commit(); err != nil {
return LabRegistrationOutcome{}, err
}
return outcome, nil
}
func addLabPackageInTransaction(ctx context.Context, tx *sql.Tx, regID string, params AddLabPackageParams) (LabRegistrationOutcome, error) {
registration, found, err := findLabRegistrationStateForUpdate(ctx, tx, regID)
if err != nil {
return LabRegistrationOutcome{}, err
}
if !found {
return labOutcome(404, false, "registration_not_found", "Registrasi laboratorium tidak ditemukan", nil), nil
}
if registration.BatalDaftar == "Y" {
return labOutcome(409, false, "registration_cancelled", "Registrasi laboratorium sudah dibatalkan", nil), nil
}
if registration.Pulang == "Y" {
return labOutcome(409, false, "registration_closed", "Registrasi laboratorium sudah ditutup", nil), nil
}
transaction, found, err := findLabTransactionStateForUpdate(ctx, tx, registration.Registration.RegID, registration.Registration.DepartemenID)
if err != nil {
return LabRegistrationOutcome{}, err
}
if !found {
return labOutcome(404, false, "transaction_not_found", "Transaksi departemen laboratorium tidak ditemukan", nil), nil
}
packageHeader, found, err := findActiveOutpatientPackage(ctx, tx, params.PackageID, registration.Registration.Tanggal)
if err != nil {
return LabRegistrationOutcome{}, err
}
if !found {
return labOutcome(404, false, "package_not_found", "Paket dispenser rawat jalan tidak ditemukan, belum disetujui, atau sudah tidak berlaku", nil), nil
}
packageServices, err := findActivePackageServices(ctx, tx, packageHeader.ID)
if err != nil {
return LabRegistrationOutcome{}, err
}
if len(packageServices) == 0 {
return LabRegistrationOutcome{}, ValidationError{Message: "Paket dispenser tidak memiliki layanan aktif"}
}
alreadyAdded, err := packageAlreadyAdded(ctx, tx, transaction.Transaction.TrxDepartemenID, packageHeader.ID)
if err != nil {
return LabRegistrationOutcome{}, err
}
if alreadyAdded {
data, err := buildLabRegistrationData(ctx, tx, registration.Registration, &transaction.Transaction)
if err != nil {
return LabRegistrationOutcome{}, err
}
data.Package = &LabPackage{PaketID: packageHeader.ID, Nama: packageHeader.Name}
return labOutcome(200, true, "package_already_added", "Paket dispenser sudah terdaftar pada kunjungan laboratorium ini", &data), nil
}
serviceIDs := make([]string, len(packageServices))
for i, service := range packageServices {
serviceIDs[i] = service.ServiceID
}
conflicts, err := findExistingLabServiceIDs(ctx, tx, transaction.Transaction.TrxDepartemenID, serviceIDs)
if err != nil {
return LabRegistrationOutcome{}, err
}
if len(conflicts) > 0 {
data, err := buildLabRegistrationData(ctx, tx, registration.Registration, &transaction.Transaction)
if err != nil {
return LabRegistrationOutcome{}, err
}
data.SkippedLayananIDs = &conflicts
return labOutcome(409, false, "package_service_conflict", "Paket memiliki layanan yang sudah ada pada registrasi laboratorium", &data), nil
}
if registration.Registration.GroupTarifID.Valid {
active, err := exists(ctx, tx, `SELECT EXISTS(SELECT 1 FROM perusahaan_grouptarif WHERE GroupTarifID = ? AND NA = 'N')`, registration.Registration.GroupTarifID.Int64)
if err != nil {
return LabRegistrationOutcome{}, err
}
if !active {
return LabRegistrationOutcome{}, ValidationError{Message: "Group tarif registrasi tidak ditemukan atau sudah tidak aktif"}
}
}
jkt, err := time.LoadLocation("Asia/Jakarta")
if err != nil {
return LabRegistrationOutcome{}, err
}
now := time.Now().In(jkt).Format("2006-01-02 15:04:05")
dispenserID, visitNumber, err := ensurePackageDispenser(ctx, tx, registration.Registration, packageHeader, params.Channel, now)
if err != nil {
return LabRegistrationOutcome{}, err
}
classID := "Default"
if transaction.KelasID.Valid && strings.TrimSpace(transaction.KelasID.String) != "" {
classID = transaction.KelasID.String
} else if registration.KelasID.Valid && strings.TrimSpace(registration.KelasID.String) != "" {
classID = registration.KelasID.String
}
doctorID := ""
if transaction.DokterID.Valid {
doctorID = transaction.DokterID.String
} else if registration.Registration.DokterID.Valid {
doctorID = registration.Registration.DokterID.String
}
cito := "N"
if params.Cito {
cito = "Y"
}
addedIDs := make([]string, 0, len(packageServices))
for _, packageService := range packageServices {
master, found, err := findLabServiceMaster(ctx, tx, packageService.ServiceID, packageService.DepartmentID)
if err != nil {
return LabRegistrationOutcome{}, err
}
if !found {
return LabRegistrationOutcome{}, ValidationError{Message: fmt.Sprintf("Layanan %s pada paket tidak aktif di departemen %s", packageService.ServiceID, packageService.DepartmentID)}
}
operationID, err := resolvePackageOperationID(ctx, tx, packageService.HeaderService)
if err != nil {
return LabRegistrationOutcome{}, err
}
dispenserServiceID, err := insertPackageDispenserService(ctx, tx, dispenserID, registration.Registration, packageHeader.ID, packageService, operationID, params.Channel, now)
if err != nil {
return LabRegistrationOutcome{}, err
}
price := math.Max(0, parseNullableDecimal(packageService.Price))
discount := math.Max(0, parseNullableDecimal(packageService.Discount))
discountType := "Percentage"
if packageService.DiscountType.Valid && packageService.DiscountType.String != "" {
discountType = packageService.DiscountType.String
}
net := applyPackageDiscount(price, discount, discountType)
patient, guarantor := net, 0.0
if registration.Registration.GroupTarifID.Valid && registration.Registration.GroupTarifID.Int64 > 0 {
patient, guarantor = 0, net
}
resolved := resolvedLabService{
LayananID: master.LayananID, LayananDepartemenID: master.LayananDepartemenID,
Satuan: master.Satuan, UnitCost: nullDecimal(master.UnitCost), FlagTipe: master.FlagTipe,
Jumlah: 1, Cito: cito, MarkupCito: params.MarkupCito, Harga: price,
HargaDefault: price, Total: net, ShareTipe: packageString(packageService.ShareType, "Percentage"),
ShareRS: parseNullableDecimal(packageService.ShareHospital), ShareDokter: parseNullableDecimal(packageService.ShareDoctor),
ShareExternal: parseNullableDecimal(packageService.ShareExternal), ShareLain: parseNullableDecimal(packageService.ShareOther),
Catatan: params.Notes,
}
trxServiceID, err := insertPackageTransactionService(ctx, tx, resolved, discountType, discount, patient, patient, guarantor, guarantor, packageHeader.ID, registration.Registration.RegID, transaction.Transaction.TrxDepartemenID, registration.Registration.MEDRECID, packageService.DepartmentID, doctorID, classID, params.Channel, now)
if err != nil {
return LabRegistrationOutcome{}, err
}
if _, err := tx.ExecContext(ctx, `INSERT INTO trxlayanan_detail (TrxLayananID, PaketDispenserID, PaketDispenserLayananID, LoginBuat, TanggalBuat, NA) VALUES (?, ?, ?, ?, ?, 'N')`, trxServiceID, dispenserID, dispenserServiceID, params.Channel, now); err != nil {
return LabRegistrationOutcome{}, err
}
addedIDs = append(addedIDs, master.LayananID)
}
if err := insertPackageAdministration(ctx, tx, dispenserID, registration.Registration.RegID, packageHeader.ID, params.Channel, now); err != nil {
return LabRegistrationOutcome{}, err
}
if err := syncLabSpecimens(ctx, tx, transaction.Transaction.TrxDepartemenID, transaction.Transaction.TrxID, params.Channel, now); err != nil {
return LabRegistrationOutcome{}, err
}
if err := updateLabTransactionTotal(ctx, tx, transaction.Transaction.TrxDepartemenID, registration.Registration.RegID, registration.Registration.DepartemenID); err != nil {
return LabRegistrationOutcome{}, err
}
data, err := buildLabRegistrationData(ctx, tx, registration.Registration, &transaction.Transaction)
if err != nil {
return LabRegistrationOutcome{}, err
}
data.Package = &LabPackage{PaketDispenserID: dispenserID, PaketID: packageHeader.ID, Nama: packageHeader.Name, KunjunganKe: visitNumber, AddedLayananIDs: addedIDs}
return labOutcome(201, true, "package_added", "Paket dispenser berhasil ditambahkan ke registrasi laboratorium", &data), nil
}
func packageAlreadyAdded(ctx context.Context, tx *sql.Tx, trxDepartmentID int64, packageID string) (bool, error) {
var marker int
err := tx.QueryRowContext(ctx, `SELECT 1 FROM trxlayanan WHERE TrxDepartemenID = ? AND PaketID = ? AND NA = 'N' LIMIT 1 FOR UPDATE`, trxDepartmentID, packageID).Scan(&marker)
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
return err == nil, err
}
func findActiveOutpatientPackage(ctx context.Context, tx *sql.Tx, packageID string, date time.Time) (labPackageHeader, bool, error) {
var result labPackageHeader
err := tx.QueryRowContext(ctx, `SELECT PaketID, Nama, JumlahHari, TotalHarga FROM paket_header WHERE PaketID = ? AND StatusID = 2 AND RJ = 1 AND NA = 'N' AND (TglDari IS NULL OR DATE(TglDari) <= DATE(?)) AND (TglSampai IS NULL OR DATE(TglSampai) >= DATE(?)) LIMIT 1 FOR UPDATE`, packageID, date, date).Scan(&result.ID, &result.Name, &result.Days, &result.TotalPrice)
if errors.Is(err, sql.ErrNoRows) {
return labPackageHeader{}, false, nil
}
return result, err == nil, err
}
func findActivePackageServices(ctx context.Context, tx *sql.Tx, packageID string) ([]labPackageService, error) {
rows, err := tx.QueryContext(ctx, `SELECT PaketLayananID, DepartemenID, LayananID, HeaderLayanan, Jumlah, JumlahHari, Harga, TipeDiskon, Diskon, ShareTipe, ShareRS, ShareDokter, ShareExternal, ShareLain FROM paket_layanan WHERE PaketID = ? AND NA = 'N' ORDER BY PaketLayananID`, packageID)
if err != nil {
return nil, err
}
defer rows.Close()
result := make([]labPackageService, 0)
for rows.Next() {
var item labPackageService
if err := rows.Scan(&item.ID, &item.DepartmentID, &item.ServiceID, &item.HeaderService, &item.Quantity, &item.Days, &item.Price, &item.DiscountType, &item.Discount, &item.ShareType, &item.ShareHospital, &item.ShareDoctor, &item.ShareExternal, &item.ShareOther); err != nil {
return nil, err
}
result = append(result, item)
}
return result, rows.Err()
}
func ensurePackageDispenser(ctx context.Context, tx *sql.Tx, registration dbLabRegistration, header labPackageHeader, channel, now string) (int64, int64, error) {
var dispenserID int64
err := tx.QueryRowContext(ctx, `SELECT h.PaketDispenserID FROM paket_dispenser_header h WHERE h.PaketID = ? AND h.RegID = ? AND h.MEDRECID = ? AND h.NA = 'N' AND NOT EXISTS (SELECT 1 FROM paket_dispenser_layanan l WHERE l.PaketDispenserID = h.PaketDispenserID) AND NOT EXISTS (SELECT 1 FROM trxlayanan_detail d WHERE d.PaketDispenserID = h.PaketDispenserID) ORDER BY h.PaketDispenserID DESC LIMIT 1 FOR UPDATE`, header.ID, registration.RegID, registration.MEDRECID).Scan(&dispenserID)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return 0, 0, err
}
if errors.Is(err, sql.ErrNoRows) {
result, err := tx.ExecContext(ctx, `INSERT INTO paket_dispenser_header (PaketID, RegID, MEDRECID, JumlahHari, TotalHarga, LoginBuat, TanggalBuat, NA) VALUES (?, ?, ?, ?, ?, ?, ?, 'N')`, header.ID, registration.RegID, registration.MEDRECID, nullInt64Value(header.Days), nullStringValue(header.TotalPrice), channel, now)
if err != nil {
return 0, 0, err
}
dispenserID, err = result.LastInsertId()
if err != nil {
return 0, 0, err
}
}
var visit int64
err = tx.QueryRowContext(ctx, `SELECT KunjunganKe FROM paket_dispenser_episode_rj WHERE PaketDispenserID = ? AND NA = 'N' LIMIT 1 FOR UPDATE`, dispenserID).Scan(&visit)
if errors.Is(err, sql.ErrNoRows) {
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) + 1 FROM paket_dispenser_episode_rj e INNER JOIN paket_dispenser_header h ON h.PaketDispenserID = e.PaketDispenserID WHERE e.MEDRECID = ? AND h.PaketID = ? AND e.NA = 'N' AND h.NA = 'N'`, registration.MEDRECID, header.ID).Scan(&visit); err != nil {
return 0, 0, err
}
_, err = tx.ExecContext(ctx, `INSERT INTO paket_dispenser_episode_rj (PaketDispenserID, RegID, MEDRECID, KunjunganKe, LoginBuat, TanggalBuat, NA) VALUES (?, ?, ?, ?, ?, ?, 'N')`, dispenserID, registration.RegID, registration.MEDRECID, visit, channel, now)
}
return dispenserID, visit, err
}
func resolvePackageOperationID(ctx context.Context, tx *sql.Tx, header sql.NullString) (int64, error) {
if !header.Valid || strings.TrimSpace(header.String) == "" {
return 0, nil
}
var id sql.NullInt64
err := tx.QueryRowContext(ctx, `SELECT PaketDispenserOperasiID FROM paket_dispenser_operasi_header WHERE PaketOperasiHeaderID = ? LIMIT 1`, header.String).Scan(&id)
if errors.Is(err, sql.ErrNoRows) || !id.Valid || id.Int64 < 1 {
return 0, ValidationError{Message: "Mapping operasi untuk HeaderLayanan " + header.String + " tidak ditemukan"}
}
return id.Int64, err
}
func insertPackageDispenserService(ctx context.Context, tx *sql.Tx, dispenserID int64, registration dbLabRegistration, packageID string, service labPackageService, operationID int64, channel, now string) (int64, error) {
result, err := tx.ExecContext(ctx, `INSERT INTO paket_dispenser_layanan (PaketDispenserID, PaketDispenserOperasiID, RegID, PaketID, DepartemenID, LayananID, HeaderLayanan, JumlahAwal, Jumlah, JumlahHari, Harga, TipeDiskon, Diskon, ShareTipe, ShareRS, ShareDokter, ShareExternal, ShareLain, LoginBuat, TanggalBuat, NA) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'N')`, dispenserID, operationID, registration.RegID, packageID, service.DepartmentID, service.ServiceID, nullStringValue(service.HeaderService), nullStringValue(service.Quantity), nullInt64Value(service.Days), nullStringValue(service.Price), nullStringValue(service.DiscountType), nullStringValue(service.Discount), nullStringValue(service.ShareType), nullStringValue(service.ShareHospital), nullStringValue(service.ShareDoctor), nullStringValue(service.ShareExternal), nullStringValue(service.ShareOther), channel, now)
if err != nil {
return 0, err
}
return result.LastInsertId()
}
func insertPackageTransactionService(ctx context.Context, tx *sql.Tx, service resolvedLabService, discountType string, discount, patient, patientDefault, guarantor, guarantorDefault float64, packageID, regID string, trxDepartmentID int64, medrecID, departmentID, doctorID, classID, channel, now string) (int64, error) {
headerDetail := 1
if service.FlagTipe.Valid && service.FlagTipe.String == "M" {
headerDetail = 0
}
result, err := tx.ExecContext(ctx, `INSERT INTO trxlayanan (TrxDepartemenID, RegID, MEDRECID, DepartemenID, DokterID, HeaderDetail, Urutan, LevelLayanan, HeaderLayananID, LayananID, LayananDepartemenID, PaketID, JenisRegID, KelasID, Catatan, Cito, MarkUpCito, Jumlah, Satuan, MataUang, Rate, UnitCost, TipeDiskon, Diskon, HargaDefault, Harga, DitanggungPasien, DitanggungPasienDefault, DitanggungPenjamin, DitanggungPenjaminDefault, ShareTipe, ShareRS, ShareDokter, ShareExternal, ShareLain2, Tagihkan, Tampilkan, LoginBuat, TanggalBuat, NA) VALUES (?, ?, ?, ?, ?, ?, 0, 0, '', ?, ?, ?, '3', ?, ?, ?, ?, ?, ?, 'IDR', 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'Y', 'Y', ?, ?, 'N')`, trxDepartmentID, regID, medrecID, departmentID, doctorID, headerDetail, service.LayananID, service.LayananDepartemenID, packageID, classID, service.Catatan, service.Cito, service.MarkupCito, service.Jumlah, nullStringValue(service.Satuan), service.UnitCost, discountType, discount, service.HargaDefault, service.Harga, patient, patientDefault, guarantor, guarantorDefault, service.ShareTipe, service.ShareRS, service.ShareDokter, service.ShareExternal, service.ShareLain, channel, now)
if err != nil {
return 0, err
}
id, err := result.LastInsertId()
if err != nil || headerDetail != 0 {
return id, err
}
if err := insertLabServiceSupport(ctx, tx, id, service.LayananID); err != nil {
return 0, err
}
details, err := findMultipleLabDetails(ctx, tx, service.LayananID, departmentID)
if err != nil {
return 0, err
}
for _, detail := range details {
if err := insertMultipleLabDetail(ctx, tx, detail, service, regID, trxDepartmentID, medrecID, departmentID, doctorID, classID, channel, now); err != nil {
return 0, err
}
}
return id, nil
}
func insertPackageAdministration(ctx context.Context, tx *sql.Tx, dispenserID int64, regID, packageID, channel, now string) error {
_, err := tx.ExecContext(ctx, `INSERT INTO paket_dispenser_adm (PaketAdmID, PaketDispenserID, RegID, PaketID, TipeRI, Hitung, AdmRI, AdmRIBaru, TipeDiskonRI, DiskonRI, LoginBuat, TanggalBuat, NA) SELECT PaketAdmID, ?, ?, PaketID, TipeRI, 'Y', AdmRI, AdmRIBaru, TipeDiskonRI, DiskonRI, ?, ?, 'N' FROM paket_adm WHERE PaketID = ? AND NA = 'N' LIMIT 1`, dispenserID, regID, channel, now, packageID)
return err
}
func applyPackageDiscount(price, discount float64, discountType string) float64 {
if strings.EqualFold(discountType, "Percentage") {
return math.Max(0, price-price*discount/100)
}
return math.Max(0, price-discount)
}
func packageString(value sql.NullString, fallback string) string {
if value.Valid && strings.TrimSpace(value.String) != "" {
return value.String
}
return fallback
}

View File

@@ -21,7 +21,15 @@ type LabRegistrationParams struct {
DoctorID string
SenderDoctorID string
Referrer string
PatientType string
TariffPeriodID int64
CompanyID string
InsuranceID string
ProviderID string
GroupTariffID int64
PolicyNumber string
EmployeeID string
EmployeeName string
ClassID string
DailyShiftID *int64
WorkingDiagnosis string
@@ -43,6 +51,15 @@ type LabRegistrationData struct {
Layanan []LabService `json:"layanan"`
AddedLayananIDs *[]string `json:"added_layanan_ids,omitempty"`
SkippedLayananIDs *[]string `json:"skipped_layanan_ids,omitempty"`
Package *LabPackage `json:"package,omitempty"`
}
type LabPackage struct {
PaketDispenserID int64 `json:"PaketDispenserID,omitempty"`
PaketID string `json:"PaketID"`
Nama string `json:"Nama"`
KunjunganKe int64 `json:"KunjunganKe,omitempty"`
AddedLayananIDs []string `json:"added_layanan_ids,omitempty"`
}
type LabRegistration struct {
@@ -54,6 +71,11 @@ type LabRegistration struct {
JenisPasienID int64 `json:"JenisPasienID"`
JenisRegID *int64 `json:"JenisRegID"`
PeriodeTarifID int64 `json:"PeriodeTarifID"`
PerusahaanID *string `json:"PerusahaanID"`
AsuransiID *string `json:"AsuransiID"`
ProviderID *string `json:"ProviderID"`
GroupTarifID *int64 `json:"GroupTarifID"`
NomerPolis *string `json:"NomerPolis"`
}
type LabTransaction struct {
@@ -91,6 +113,11 @@ type dbLabRegistration struct {
JenisPasienID int64
JenisRegID sql.NullInt64
PeriodeTarifID int64
PerusahaanID sql.NullString
AsuransiID sql.NullString
ProviderID sql.NullString
GroupTarifID sql.NullInt64
NomerPolis sql.NullString
}
type dbLabTransaction struct {
@@ -161,6 +188,9 @@ func registerLabInTransaction(ctx context.Context, tx *sql.Tx, params LabRegistr
if !departmentActive {
return LabRegistrationOutcome{}, ValidationError{Message: "Departemen tujuan bukan departemen laboratorium aktif"}
}
if err := validateLabGuarantor(ctx, tx, params); err != nil {
return LabRegistrationOutcome{}, err
}
patient, found, err := findLabPatientForUpdate(ctx, tx, params.MedrecID)
if err != nil {
@@ -175,6 +205,15 @@ func registerLabInTransaction(ctx context.Context, tx *sql.Tx, params LabRegistr
return LabRegistrationOutcome{}, err
}
if found {
if !sameLabGuarantor(existing, params) {
return labOutcome(
409,
false,
"registration_guarantor_conflict",
"Pasien sudah memiliki registrasi laboratorium pada hari yang sama dengan penjamin berbeda",
nil,
), nil
}
trx, trxFound, err := findLabTransaction(ctx, tx, existing.RegID, params.DepartmentID)
if err != nil {
return LabRegistrationOutcome{}, err
@@ -223,10 +262,11 @@ func registerLabInTransaction(ctx context.Context, tx *sql.Tx, params LabRegistr
INSERT INTO regpas (
RegID, MEDRECID, Nama, ALAMATEXTPAS, TELPEXTPAS, TglLahir, JnsKelamin,
KunjunganKe, Tanggal, JenisPasienID, PeriodeTarifID, PerusahaanID,
GroupTarifID, AsuransiID, ProviderID, DepartemenID, Perujuk, JenisRegID,
GroupTarifID, AsuransiID, ProviderID, NomerPolis, NIK, NamaKaryawan,
DepartemenID, Perujuk, JenisRegID,
DokterID, DokterID2, KelasID, MataUang, Rate, Pulang, BolehPulang, Lunas,
BatalDaftar, Catatan, ShiftHarianIDDaftar, LoginBuat, TanggalBuat, NA
) VALUES (?, ?, ?, '', '', ?, ?, ?, ?, '0', ?, '', '0', '', '', ?, ?, '3', ?, ?, ?, 'IDR', 1, 'N', 'N', 'N', 'N', ?, ?, ?, ?, 'N')`,
) VALUES (?, ?, ?, '', '', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '3', ?, ?, ?, 'IDR', 1, 'N', 'N', 'N', 'N', ?, ?, ?, ?, 'N')`,
regID,
patient.MEDRECID,
patient.Nama,
@@ -234,7 +274,15 @@ func registerLabInTransaction(ctx context.Context, tx *sql.Tx, params LabRegistr
nullStringValue(patient.Kelamin),
previousVisits+1,
nowText,
params.PatientType,
params.TariffPeriodID,
params.CompanyID,
params.GroupTariffID,
params.InsuranceID,
params.ProviderID,
params.PolicyNumber,
params.EmployeeID,
params.EmployeeName,
params.DepartmentID,
strings.TrimSpace(params.Referrer),
params.DoctorID,
@@ -295,11 +343,108 @@ func registerLabInTransaction(ctx context.Context, tx *sql.Tx, params LabRegistr
return labOutcome(201, true, "registered", "Registrasi laboratorium berhasil disimpan", &data), nil
}
func validateLabGuarantor(ctx context.Context, tx *sql.Tx, params LabRegistrationParams) error {
if params.PatientType == "0" {
return nil
}
active, err := exists(ctx, tx, `
SELECT EXISTS(
SELECT 1 FROM jenispasien
WHERE JenisPasienID = ? AND NA = 'N'
)`, params.PatientType)
if err != nil {
return err
}
if !active {
return ValidationError{Message: "Jenis pasien penjamin tidak ditemukan atau sudah tidak aktif"}
}
guarantors := []struct {
label string
id string
}{
{label: "Perusahaan", id: params.CompanyID},
{label: "Asuransi/penanggung", id: params.InsuranceID},
{label: "Provider", id: params.ProviderID},
}
for _, guarantor := range guarantors {
if guarantor.id == "" {
continue
}
active, err = exists(ctx, tx, `
SELECT EXISTS(
SELECT 1 FROM perusahaan
WHERE PerusahaanID = ? AND NA = 'N'
)`, guarantor.id)
if err != nil {
return err
}
if !active {
return ValidationError{Message: guarantor.label + " tidak ditemukan atau sudah tidak aktif"}
}
}
var tariffPeriodID int64
err = tx.QueryRowContext(ctx, `
SELECT pg.PeriodeTarifID
FROM perusahaan_grouptarif AS pg
INNER JOIN perusahaan_tarif AS pt
ON pt.Perusahaan_TarifID = pg.Perusahaan_TarifID
WHERE pg.GroupTarifID = ?
AND pt.PerusahaanID = ?
AND pg.NA = 'N'
AND DATE(pg.TanggalBerlaku) <= ?
AND DATE(pg.TanggalBerakhir) >= ?
LIMIT 1`,
params.GroupTariffID,
params.CompanyID,
params.RegistrationDate.Format("2006-01-02"),
params.RegistrationDate.Format("2006-01-02"),
).Scan(&tariffPeriodID)
if errors.Is(err, sql.ErrNoRows) {
return ValidationError{Message: "Group tarif tidak aktif, tidak berlaku pada tanggal registrasi, atau bukan milik perusahaan yang dipilih"}
}
if err != nil {
return err
}
if tariffPeriodID != params.TariffPeriodID {
return ValidationError{Message: fmt.Sprintf("Periode tarif harus %d sesuai group tarif yang dipilih", tariffPeriodID)}
}
return nil
}
func sameLabGuarantor(existing dbLabRegistration, params LabRegistrationParams) bool {
return strconv.FormatInt(existing.JenisPasienID, 10) == params.PatientType &&
existing.PeriodeTarifID == params.TariffPeriodID &&
nullInt64(existing.GroupTarifID) == params.GroupTariffID &&
strings.TrimSpace(nullableString(existing.PerusahaanID)) == params.CompanyID &&
strings.TrimSpace(nullableString(existing.AsuransiID)) == params.InsuranceID &&
strings.TrimSpace(nullableString(existing.ProviderID)) == params.ProviderID &&
strings.TrimSpace(nullableString(existing.NomerPolis)) == params.PolicyNumber
}
func nullInt64(value sql.NullInt64) int64 {
if !value.Valid {
return 0
}
return value.Int64
}
func nullableString(value sql.NullString) string {
if !value.Valid {
return ""
}
return value.String
}
func findLabRegistrationByID(ctx context.Context, tx *sql.Tx, regID string) (dbLabRegistration, error) {
var registration dbLabRegistration
err := tx.QueryRowContext(ctx, `
SELECT RegID, MEDRECID, Tanggal, DepartemenID, DokterID,
JenisPasienID, JenisRegID, PeriodeTarifID
JenisPasienID, JenisRegID, PeriodeTarifID, PerusahaanID,
AsuransiID, ProviderID, GroupTarifID, NomerPolis
FROM regpas WHERE RegID = ? LIMIT 1`, regID).Scan(
&registration.RegID,
&registration.MEDRECID,
@@ -309,6 +454,11 @@ func findLabRegistrationByID(ctx context.Context, tx *sql.Tx, regID string) (dbL
&registration.JenisPasienID,
&registration.JenisRegID,
&registration.PeriodeTarifID,
&registration.PerusahaanID,
&registration.AsuransiID,
&registration.ProviderID,
&registration.GroupTarifID,
&registration.NomerPolis,
)
return registration, err
}
@@ -355,7 +505,8 @@ func findExistingLabRegistrationForUpdate(
var registration dbLabRegistration
err := tx.QueryRowContext(ctx, `
SELECT RegID, MEDRECID, Tanggal, DepartemenID, DokterID,
JenisPasienID, JenisRegID, PeriodeTarifID
JenisPasienID, JenisRegID, PeriodeTarifID, PerusahaanID,
AsuransiID, ProviderID, GroupTarifID, NomerPolis
FROM regpas
WHERE MEDRECID = ?
AND DepartemenID = ?
@@ -371,6 +522,11 @@ func findExistingLabRegistrationForUpdate(
&registration.JenisPasienID,
&registration.JenisRegID,
&registration.PeriodeTarifID,
&registration.PerusahaanID,
&registration.AsuransiID,
&registration.ProviderID,
&registration.GroupTarifID,
&registration.NomerPolis,
)
if errors.Is(err, sql.ErrNoRows) {
return dbLabRegistration{}, false, nil
@@ -544,6 +700,11 @@ func buildLabRegistrationData(ctx context.Context, tx *sql.Tx, registration dbLa
JenisPasienID: registration.JenisPasienID,
JenisRegID: nullInt64Ptr(registration.JenisRegID),
PeriodeTarifID: registration.PeriodeTarifID,
PerusahaanID: nullStringPtr(registration.PerusahaanID),
AsuransiID: nullStringPtr(registration.AsuransiID),
ProviderID: nullStringPtr(registration.ProviderID),
GroupTarifID: nullInt64Ptr(registration.GroupTarifID),
NomerPolis: nullStringPtr(registration.NomerPolis),
},
Layanan: make([]LabService, 0),
}

View File

@@ -261,7 +261,8 @@ func findLabRegistrationStateForUpdate(ctx context.Context, tx *sql.Tx, regID st
var state labRegistrationServiceState
err := tx.QueryRowContext(ctx, `
SELECT RegID, MEDRECID, Tanggal, DepartemenID, DokterID,
JenisPasienID, JenisRegID, PeriodeTarifID, KelasID, BatalDaftar, Pulang
JenisPasienID, JenisRegID, PeriodeTarifID, PerusahaanID, AsuransiID,
ProviderID, GroupTarifID, NomerPolis, KelasID, BatalDaftar, Pulang
FROM regpas WHERE RegID = ? AND NA = 'N' LIMIT 1 FOR UPDATE`, regID).Scan(
&state.Registration.RegID,
&state.Registration.MEDRECID,
@@ -271,6 +272,11 @@ func findLabRegistrationStateForUpdate(ctx context.Context, tx *sql.Tx, regID st
&state.Registration.JenisPasienID,
&state.Registration.JenisRegID,
&state.Registration.PeriodeTarifID,
&state.Registration.PerusahaanID,
&state.Registration.AsuransiID,
&state.Registration.ProviderID,
&state.Registration.GroupTarifID,
&state.Registration.NomerPolis,
&state.KelasID,
&state.BatalDaftar,
&state.Pulang,

View File

@@ -26,6 +26,9 @@ type MedrecParams struct {
Village string
PostalCode string
CompanyID string
EmployeeID string
DependantID string
EmployeeName string
Channel string
}
@@ -235,10 +238,6 @@ func insertMedrec(ctx context.Context, tx *sql.Tx, medrecID string, params Medre
if mobile == "" {
mobile = params.Phone
}
var nik any
if params.IdentityType == "KTP" {
nik = params.IdentityNumber
}
firstName := params.Name
if index := strings.IndexByte(params.Name, ' '); index >= 0 {
firstName = params.Name[:index]
@@ -252,14 +251,16 @@ func insertMedrec(ctx context.Context, tx *sql.Tx, medrecID string, params Medre
_, err = tx.ExecContext(ctx, `
INSERT INTO medrec (
MEDRECID, KodeRS, Nama, Panggilan, Kelamin, TanggalLahir, TempatLahir,
JenisNomerSosial, NomerSosial, NIK, Telepon, Ponsel, Email, Alamat,
Propinsi, Kota, Kecamatan, Kelurahan, KodePos, PerusahaanID,
JenisNomerSosial, NomerSosial, NIK, DependantID, NamaKaryawan,
Telepon, Ponsel, Email, Alamat, Propinsi, Kota, Kecamatan, Kelurahan,
KodePos, PerusahaanID,
Mati, NA, LoginBuat, TanggalBuat, TanggalEdit
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'N', 'N', ?, ?, ?)`,
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'N', 'N', ?, ?, ?)`,
medrecID, params.HospitalCode, params.Name, firstName, params.Gender,
params.DateOfBirth.Format("2006-01-02"), params.PlaceOfBirth,
params.IdentityType, params.IdentityNumber, nik, phone, mobile, params.Email,
params.Address, params.Province, params.City, params.District, params.Village,
params.IdentityType, params.IdentityNumber, params.EmployeeID, params.DependantID,
params.EmployeeName, phone, mobile, params.Email, params.Address, params.Province,
params.City, params.District, params.Village,
params.PostalCode, params.CompanyID, params.Channel, now, now)
if err != nil {
return MedrecPatient{}, err

View File

@@ -50,45 +50,54 @@ type PaketSummary struct {
}
type PaketDetail struct {
PaketID string `json:"PaketID"`
Nama string `json:"Nama"`
JenisPaket string `json:"JenisPaket"`
RJ int `json:"RJ"`
RI int `json:"RI"`
TglDari string `json:"TglDari"`
TglSampai string `json:"TglSampai"`
MultiVisit int `json:"MultiVisit"`
HeaderPaket string `json:"HeaderPaket"`
KelasID string `json:"KelasID"`
JumlahHari int `json:"JumlahHari"`
TotalHarga string `json:"TotalHarga"`
Keterangan string `json:"Keterangan"`
StatusID int `json:"StatusID"`
LoginBuat string `json:"LoginBuat"`
TanggalBuat string `json:"TanggalBuat"`
LoginEdit string `json:"LoginEdit"`
TanggalEdit string `json:"TanggalEdit"`
NA string `json:"NA"`
Layanan []PaketLayanan `json:"layanan"`
Operasi []PaketOperasi `json:"operasi"`
Item []PaketItem `json:"item"`
Kamar []PaketKamar `json:"kamar"`
PaketID string `json:"PaketID"`
Nama string `json:"Nama"`
JenisPaket string `json:"JenisPaket"`
RJ int `json:"RJ"`
RI int `json:"RI"`
TglDari string `json:"TglDari"`
TglSampai string `json:"TglSampai"`
MultiVisit int `json:"MultiVisit"`
HeaderPaket string `json:"HeaderPaket"`
KelasID string `json:"KelasID"`
JumlahHari int `json:"JumlahHari"`
TotalHarga string `json:"TotalHarga"`
Keterangan string `json:"Keterangan"`
StatusID int `json:"StatusID"`
LoginBuat string `json:"LoginBuat"`
TanggalBuat string `json:"TanggalBuat"`
LoginEdit string `json:"LoginEdit"`
TanggalEdit string `json:"TanggalEdit"`
NA string `json:"NA"`
PeriodeTarif *PaketPeriodeTarif `json:"PeriodeTarif"`
Layanan []PaketLayanan `json:"layanan"`
Operasi []PaketOperasi `json:"operasi"`
Item []PaketItem `json:"item"`
Kamar []PaketKamar `json:"kamar"`
}
type PaketPeriodeTarif struct {
PeriodeTarifID int64 `json:"PeriodeTarifID"`
Keterangan *string `json:"Keterangan"`
TanggalBerlaku string `json:"TanggalBerlaku"`
TanggalBerakhir string `json:"TanggalBerakhir"`
}
type PaketLayanan struct {
PaketLayananID int64 `json:"PaketLayananID"`
LayananID string `json:"LayananID"`
NamaLayanan *string `json:"NamaLayanan"`
NamaDepartemen *string `json:"NamaDepartemen"`
Jumlah string `json:"Jumlah"`
JumlahHari int `json:"JumlahHari"`
Harga string `json:"Harga"`
HargaDasar string `json:"HargaDasar"`
Diskon string `json:"Diskon"`
TipeDiskon string `json:"TipeDiskon"`
ShareRS string `json:"ShareRS"`
ShareDokter string `json:"ShareDokter"`
Subtotal string `json:"Subtotal"`
PaketLayananID int64 `json:"PaketLayananID"`
LayananID string `json:"LayananID"`
NamaLayanan *string `json:"NamaLayanan"`
NamaDepartemen *string `json:"NamaDepartemen"`
Jumlah string `json:"Jumlah"`
JumlahHari int `json:"JumlahHari"`
Harga string `json:"Harga"`
HargaDasar string `json:"HargaDasar"`
HargaDasarSource string `json:"HargaDasarSource"`
Diskon string `json:"Diskon"`
TipeDiskon string `json:"TipeDiskon"`
ShareRS string `json:"ShareRS"`
ShareDokter string `json:"ShareDokter"`
Subtotal string `json:"Subtotal"`
}
type PaketOperasi struct {
@@ -243,12 +252,13 @@ func (r MySQLLayananRepository) GetPaket(ctx context.Context, id string) (*Paket
result.TanggalBuat = laravelDateJSON(tanggalBuat)
result.TanggalEdit = laravelDateJSON(tanggalEdit)
tariffTable, err := r.activePaketTariffTable(ctx)
periodeTarif, tariffTable, err := r.activePaketTariff(ctx)
if err != nil {
return nil, err
}
result.PeriodeTarif = periodeTarif
pricing := newPaketBasePricing()
if result.Layanan, err = r.paketLayanan(ctx, id, result.KelasID, tariffTable, pricing); err != nil {
if result.Layanan, err = r.paketLayanan(ctx, id, result.KelasID, periodeTarif, tariffTable, pricing); err != nil {
return nil, err
}
if result.Item, err = r.paketItem(ctx, id, pricing); err != nil {
@@ -275,42 +285,51 @@ func newPaketBasePricing() *paketBasePricing {
}
}
func (r MySQLLayananRepository) activePaketTariffTable(ctx context.Context) (string, error) {
var periodID int64
func (r MySQLLayananRepository) activePaketTariff(ctx context.Context) (*PaketPeriodeTarif, string, error) {
var period PaketPeriodeTarif
var keterangan sql.NullString
err := r.db.QueryRowContext(ctx, `
SELECT PeriodeTarifID
SELECT PeriodeTarifID, Keterangan,
CAST(TanggalBerlaku AS CHAR), CAST(TanggalBerakhir AS CHAR)
FROM periode_tarif
WHERE DATE(TanggalBerlaku) <= CURRENT_DATE()
AND DATE(TanggalBerakhir) >= CURRENT_DATE()
AND NA = 'N'
ORDER BY PeriodeTarifID DESC
LIMIT 1`).Scan(&periodID)
LIMIT 1`).Scan(
&period.PeriodeTarifID,
&keterangan,
&period.TanggalBerlaku,
&period.TanggalBerakhir,
)
if err == sql.ErrNoRows {
return "", nil
return nil, "", nil
}
if err != nil {
return "", err
return nil, "", err
}
period.Keterangan = nullStringPtr(keterangan)
tableName := "harga_layanan_" + strconv.FormatInt(periodID, 10)
tableName := "harga_layanan_" + strconv.FormatInt(period.PeriodeTarifID, 10)
exists, err := r.tableExists(ctx, tableName)
if err != nil || !exists {
return "", err
return &period, "", err
}
return tableName, nil
return &period, tableName, nil
}
func (r MySQLLayananRepository) paketLayanan(
ctx context.Context,
id string,
classID string,
periodeTarif *PaketPeriodeTarif,
tariffTable string,
pricing *paketBasePricing,
) ([]PaketLayanan, error) {
basePriceSelect := "COALESCE(ml.DefaultHarga, 0)"
tariffPriceSelect := "NULL"
args := make([]any, 0, 3)
if tariffTable != "" {
basePriceSelect = fmt.Sprintf(`COALESCE((
tariffPriceSelect = fmt.Sprintf(`(
SELECT tarif.Harga
FROM %s tarif
INNER JOIN layanandepartemen ld
@@ -319,6 +338,7 @@ func (r MySQLLayananRepository) paketLayanan(
WHERE ld.LayananID = pl.LayananID
AND tarif.KelasID IN (?, 'Default')
AND tarif.NA = 'N'
AND tarif.Harga > 0
ORDER BY CASE
WHEN tarif.KelasID = ? AND ld.DepartemenID <=> pl.DepartemenID THEN 1
WHEN tarif.KelasID = ? THEN 2
@@ -326,7 +346,7 @@ func (r MySQLLayananRepository) paketLayanan(
ELSE 4
END, tarif.HargaLayananID
LIMIT 1
), ml.DefaultHarga, 0)`, tariffTable)
)`, tariffTable)
args = append(args, classID, classID, classID)
}
args = append(args, id)
@@ -335,44 +355,150 @@ func (r MySQLLayananRepository) paketLayanan(
CAST(pl.Jumlah AS CHAR), pl.JumlahHari, CAST(pl.Harga AS CHAR),
CAST(pl.Diskon AS CHAR), pl.TipeDiskon, CAST(pl.ShareRS AS CHAR),
CAST(pl.ShareDokter AS CHAR), CAST(pl.Subtotal AS CHAR),
%s, CAST(pl.HeaderLayanan AS CHAR)
%s, COALESCE(ml.DefaultHarga, 0), COALESCE(ml.LayananKode, ''),
CAST(pl.HeaderLayanan AS CHAR)
FROM paket_layanan pl
LEFT JOIN masterlayanan ml ON ml.LayananID = pl.LayananID
LEFT JOIN departemen d ON d.DepartemenID = pl.DepartemenID AND d.NA = 'N'
WHERE pl.PaketID = ? AND pl.NA = 'N'
ORDER BY pl.PaketLayananID`, basePriceSelect)
ORDER BY pl.PaketLayananID`, tariffPriceSelect)
rows, err := r.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
result := make([]PaketLayanan, 0)
type servicePricing struct {
tariffPrice sql.NullFloat64
defaultPrice float64
serviceCode string
operationHeader sql.NullString
}
pricingRows := make([]servicePricing, 0)
needsAdministrationPrice := false
for rows.Next() {
var item PaketLayanan
var namaLayanan, namaDepartemen sql.NullString
var harga, shareRS, shareDokter, subtotal string
var basePrice float64
var operationHeader sql.NullString
var servicePrice servicePricing
if err := rows.Scan(
&item.PaketLayananID, &item.LayananID, &namaLayanan, &namaDepartemen,
&item.Jumlah, &item.JumlahHari, &harga, &item.Diskon, &item.TipeDiskon,
&shareRS, &shareDokter, &subtotal, &basePrice, &operationHeader,
&shareRS, &shareDokter, &subtotal, &servicePrice.tariffPrice,
&servicePrice.defaultPrice, &servicePrice.serviceCode, &servicePrice.operationHeader,
); err != nil {
return nil, err
}
item.NamaLayanan = nullStringPtr(namaLayanan)
item.NamaDepartemen = nullStringPtr(namaDepartemen)
item.Harga = phpNumberFormat(parseDecimal(harga), 0)
item.HargaDasar = phpNumberFormat(basePrice, 0)
item.ShareRS = phpNumberFormat(parseDecimal(shareRS), 2)
item.ShareDokter = phpNumberFormat(parseDecimal(shareDokter), 0)
item.Subtotal = phpNumberFormat(parseDecimal(subtotal), 0)
if operationHeader.Valid {
pricing.layananByOperation[operationHeader.String] += basePrice * parseDecimal(item.Jumlah)
if servicePrice.tariffPrice.Float64 <= 0 && servicePrice.defaultPrice <= 0 && isAdministrationService(servicePrice.serviceCode) {
needsAdministrationPrice = true
}
result = append(result, item)
pricingRows = append(pricingRows, servicePrice)
}
return result, rows.Err()
if err := rows.Err(); err != nil {
return nil, err
}
administrationPrice := 0.0
administrationSource := "NOT_AVAILABLE"
if needsAdministrationPrice {
var err error
administrationPrice, administrationSource, err = r.paketAdministrationBasePrice(ctx, periodeTarif)
if err != nil {
return nil, err
}
}
for index := range result {
servicePrice := pricingRows[index]
basePrice, source := paketServiceBasePrice(
servicePrice.tariffPrice,
servicePrice.defaultPrice,
servicePrice.serviceCode,
administrationPrice,
administrationSource,
)
result[index].HargaDasar = phpNumberFormat(basePrice, 0)
result[index].HargaDasarSource = source
if servicePrice.operationHeader.Valid {
pricing.layananByOperation[servicePrice.operationHeader.String] += basePrice * parseDecimal(result[index].Jumlah)
}
}
return result, nil
}
func paketServiceBasePrice(
tariffPrice sql.NullFloat64,
defaultPrice float64,
serviceCode string,
administrationPrice float64,
administrationSource string,
) (float64, string) {
if tariffPrice.Valid && tariffPrice.Float64 > 0 {
return tariffPrice.Float64, "TARIF_PERIODE"
}
if defaultPrice > 0 {
return defaultPrice, "MASTER_DEFAULT"
}
if isAdministrationService(serviceCode) && administrationPrice > 0 {
return administrationPrice, administrationSource
}
return 0, "NOT_AVAILABLE"
}
func isAdministrationService(serviceCode string) bool {
switch strings.ToUpper(strings.TrimSpace(serviceCode)) {
case "AN001", "AD001":
return true
default:
return false
}
}
func (r MySQLLayananRepository) paketAdministrationBasePrice(
ctx context.Context,
periodeTarif *PaketPeriodeTarif,
) (float64, string, error) {
if periodeTarif != nil {
exists, err := r.tableExists(ctx, "adm_periode")
if err != nil {
return 0, "", err
}
if exists {
var price sql.NullFloat64
err = r.db.QueryRowContext(ctx, `
SELECT NilaiAdministrasi
FROM adm_periode
WHERE JenisRegID = 0 AND PeriodeTarifID = ?
ORDER BY AdmPeriodeID DESC
LIMIT 1`, periodeTarif.PeriodeTarifID).Scan(&price)
if err != nil && err != sql.ErrNoRows {
return 0, "", err
}
if price.Valid && price.Float64 > 0 {
return price.Float64, "ADM_PERIODE", nil
}
}
}
var price sql.NullFloat64
err := r.db.QueryRowContext(ctx, `
SELECT NilaiAdministrasi
FROM jenisreg
WHERE JenisRegID = 0
LIMIT 1`).Scan(&price)
if err != nil && err != sql.ErrNoRows {
return 0, "", err
}
if price.Valid && price.Float64 > 0 {
return price.Float64, "BIAYA_ADMIN", nil
}
return 0, "NOT_AVAILABLE", nil
}
func (r MySQLLayananRepository) paketOperasi(ctx context.Context, id string, pricing *paketBasePricing) ([]PaketOperasi, error) {

View File

@@ -94,3 +94,62 @@ func TestPaketItemBasePriceFallbackParity(t *testing.T) {
})
}
}
func TestPaketServiceBasePriceFallbackParity(t *testing.T) {
tests := []struct {
name string
tariffPrice sql.NullFloat64
defaultPrice float64
serviceCode string
administrationPrice float64
administrationSource string
expectedPrice float64
expectedSource string
}{
{
name: "positive period tariff has highest priority",
tariffPrice: sql.NullFloat64{Float64: 150000, Valid: true},
defaultPrice: 100000, serviceCode: "LAB001",
expectedPrice: 150000, expectedSource: "TARIF_PERIODE",
},
{
name: "zero period tariff does not override master default",
tariffPrice: sql.NullFloat64{Float64: 0, Valid: true},
defaultPrice: 100000, serviceCode: "LAB001",
expectedPrice: 100000, expectedSource: "MASTER_DEFAULT",
},
{
name: "AN001 uses administration period fallback",
serviceCode: "an001", administrationPrice: 75000,
administrationSource: "ADM_PERIODE",
expectedPrice: 75000, expectedSource: "ADM_PERIODE",
},
{
name: "AD001 uses registration master fallback",
serviceCode: " AD001 ", administrationPrice: 50000,
administrationSource: "BIAYA_ADMIN",
expectedPrice: 50000, expectedSource: "BIAYA_ADMIN",
},
{
name: "non administration service does not use administration fallback",
serviceCode: "LAB001", administrationPrice: 50000,
administrationSource: "BIAYA_ADMIN",
expectedPrice: 0, expectedSource: "NOT_AVAILABLE",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
price, source := paketServiceBasePrice(
test.tariffPrice,
test.defaultPrice,
test.serviceCode,
test.administrationPrice,
test.administrationSource,
)
if price != test.expectedPrice || source != test.expectedSource {
t.Fatalf("price=%v source=%q want price=%v source=%q", price, source, test.expectedPrice, test.expectedSource)
}
})
}
}

View File

@@ -103,10 +103,15 @@ func (r MySQLLayananRepository) ListTarifKerjasama(ctx context.Context, params T
query := `SELECT pt.Perusahaan_TarifID, pt.PerusahaanID, p.KodePerusahaan,
p.Nama, pt.PenanggungJawab, p.Alamat, pt.TeleponPenanggungJawab,
pt.EmailPenanggungJawab,
(SELECT COUNT(*) FROM perusahaan_grouptarif pg
WHERE pg.Perusahaan_TarifID = pt.Perusahaan_TarifID AND pg.NA = 'N')
COALESCE(pg.JumlahGroupTarif, 0)
FROM perusahaan_tarif pt
INNER JOIN perusahaan p ON p.PerusahaanID = pt.PerusahaanID ` + where + `
INNER JOIN perusahaan p ON p.PerusahaanID = pt.PerusahaanID
LEFT JOIN (
SELECT Perusahaan_TarifID, COUNT(*) AS JumlahGroupTarif
FROM perusahaan_grouptarif
WHERE NA = 'N'
GROUP BY Perusahaan_TarifID
) pg ON pg.Perusahaan_TarifID = pt.Perusahaan_TarifID ` + where + `
ORDER BY p.Nama, pt.Perusahaan_TarifID
LIMIT ? OFFSET ?`
queryArgs := append(append([]any{}, args...), params.PerPage, (params.Page-1)*params.PerPage)