feat(cpone): migrate lab registration endpoints
This commit is contained in:
@@ -16,12 +16,23 @@ type LayananRepository interface {
|
||||
MappingHarga(ctx context.Context, params repository.MappingHargaParams) ([]repository.TarifLayanan, error)
|
||||
}
|
||||
|
||||
type Handler struct {
|
||||
repo LayananRepository
|
||||
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)
|
||||
}
|
||||
|
||||
func NewHandler(repo LayananRepository) Handler {
|
||||
return Handler{repo: repo}
|
||||
type Handler struct {
|
||||
layananRepo LayananRepository
|
||||
labRepo LabRegistrationRepository
|
||||
}
|
||||
|
||||
type CPONERepository interface {
|
||||
LayananRepository
|
||||
LabRegistrationRepository
|
||||
}
|
||||
|
||||
func NewHandler(repo CPONERepository) Handler {
|
||||
return Handler{layananRepo: repo, labRepo: repo}
|
||||
}
|
||||
|
||||
func (h Handler) MappingHarga(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -55,7 +66,7 @@ func (h Handler) MappingHarga(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := h.repo.MappingHarga(r.Context(), repository.MappingHargaParams{
|
||||
rows, err := h.layananRepo.MappingHarga(r.Context(), repository.MappingHargaParams{
|
||||
PeriodeTarifID: periodeTarifID,
|
||||
LayananIDs: layananIDs,
|
||||
Search: strings.TrimSpace(r.URL.Query().Get("search")),
|
||||
|
||||
331
internal/http/lab_registration.go
Normal file
331
internal/http/lab_registration.go
Normal file
@@ -0,0 +1,331 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"primaya-api/cpone-middleware/internal/repository"
|
||||
)
|
||||
|
||||
var integerValue = regexp.MustCompile(`^[+-]?\d+$`)
|
||||
|
||||
type LabRegistrationResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
Data *repository.LabRegistrationData `json:"data"`
|
||||
}
|
||||
|
||||
type validatedLabRegistration struct {
|
||||
Params repository.LabRegistrationParams
|
||||
}
|
||||
|
||||
func (h Handler) RegisterLab(w http.ResponseWriter, r *http.Request) {
|
||||
payload, err := decodeJSONObject(r)
|
||||
if err != nil {
|
||||
writeLabResponse(w, http.StatusUnprocessableEntity, false, "validation_error", err.Error(), nil)
|
||||
return
|
||||
}
|
||||
|
||||
validated, validationMessage := validateLabRegistration(payload)
|
||||
if validationMessage != "" {
|
||||
writeLabResponse(w, http.StatusUnprocessableEntity, false, "validation_error", validationMessage, nil)
|
||||
return
|
||||
}
|
||||
|
||||
outcome, err := h.labRepo.RegisterLab(r.Context(), validated.Params)
|
||||
if err != nil {
|
||||
var validationErr repository.ValidationError
|
||||
if errors.As(err, &validationErr) {
|
||||
writeLabResponse(w, http.StatusUnprocessableEntity, false, "validation_error", validationErr.Message, 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 decodeJSONObject(r *http.Request) (map[string]any, error) {
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
decoder.UseNumber()
|
||||
|
||||
payload := make(map[string]any)
|
||||
if err := decoder.Decode(&payload); err != nil {
|
||||
// Laravel treats an empty, malformed, or non-object JSON body as empty
|
||||
// request input; validation then reports the first required field.
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
var trailing any
|
||||
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
|
||||
return make(map[string]any), nil
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
func validateLabRegistration(payload map[string]any) (validatedLabRegistration, string) {
|
||||
medrecID, message := requiredString(payload, "medrec_id", 50, "Medrec ID wajib diisi")
|
||||
if message != "" {
|
||||
return validatedLabRegistration{}, message
|
||||
}
|
||||
|
||||
jakarta, err := time.LoadLocation("Asia/Jakarta")
|
||||
if err != nil {
|
||||
return validatedLabRegistration{}, err.Error()
|
||||
}
|
||||
registrationDate := time.Now().In(jakarta)
|
||||
if value, present := nullableValue(payload, "tanggal"); present {
|
||||
dateText, ok := value.(string)
|
||||
if !ok || !validDateFormat(dateText, jakarta) {
|
||||
return validatedLabRegistration{}, "Tanggal registrasi harus format Y-m-d"
|
||||
}
|
||||
registrationDate, _ = time.ParseInLocation("2006-01-02", dateText, jakarta)
|
||||
}
|
||||
|
||||
departmentID, message := requiredString(payload, "departemen_id", 50, "Departemen wajib diisi untuk membuat registrasi")
|
||||
if message != "" {
|
||||
return validatedLabRegistration{}, message
|
||||
}
|
||||
doctorID, message := requiredString(payload, "dokter_id", 50, "Dokter penanggung jawab wajib diisi")
|
||||
if message != "" {
|
||||
return validatedLabRegistration{}, message
|
||||
}
|
||||
|
||||
senderDoctorID, senderPresent, message := optionalString(payload, "dokter_pengirim_id", 50)
|
||||
if message != "" {
|
||||
return validatedLabRegistration{}, message
|
||||
}
|
||||
referrer, referrerPresent, message := optionalString(payload, "perujuk", 255)
|
||||
if message != "" {
|
||||
return validatedLabRegistration{}, message
|
||||
}
|
||||
if !senderPresent && !referrerPresent {
|
||||
return validatedLabRegistration{}, "Dokter pengirim atau perujuk wajib diisi"
|
||||
}
|
||||
|
||||
patientType, patientTypePresent, message := optionalString(payload, "jenis_pasien_id", 0)
|
||||
if message != "" {
|
||||
return validatedLabRegistration{}, message
|
||||
}
|
||||
if patientTypePresent && patientType != "0" {
|
||||
return validatedLabRegistration{}, "Registrasi laboratorium API saat ini hanya mendukung pasien umum"
|
||||
}
|
||||
|
||||
tariffPeriodID, message := requiredInteger(payload, "periode_tarif_id", "Periode tarif wajib diisi")
|
||||
if message != "" {
|
||||
return validatedLabRegistration{}, message
|
||||
}
|
||||
if tariffPeriodID < 1 {
|
||||
return validatedLabRegistration{}, "The periode tarif id must be at least 1."
|
||||
}
|
||||
|
||||
classID, classPresent, message := optionalString(payload, "kelas_id", 50)
|
||||
if message != "" {
|
||||
return validatedLabRegistration{}, message
|
||||
}
|
||||
if !classPresent || strings.TrimSpace(classID) == "" {
|
||||
classID = "Default"
|
||||
} else {
|
||||
classID = strings.TrimSpace(classID)
|
||||
}
|
||||
|
||||
var dailyShiftID *int64
|
||||
if value, present := nullableValue(payload, "shift_harian_id"); present {
|
||||
parsed, ok := parseInteger(value)
|
||||
if !ok {
|
||||
return validatedLabRegistration{}, "The shift harian id must be an integer."
|
||||
}
|
||||
if parsed < 1 {
|
||||
return validatedLabRegistration{}, "The shift harian id must be at least 1."
|
||||
}
|
||||
dailyShiftID = &parsed
|
||||
}
|
||||
|
||||
workingDiagnosis, message := requiredString(payload, "diagnosa_kerja", 1000, "Diagnosa kerja wajib diisi")
|
||||
if message != "" {
|
||||
return validatedLabRegistration{}, message
|
||||
}
|
||||
if utf8.RuneCountInString(workingDiagnosis) < 3 {
|
||||
return validatedLabRegistration{}, "Diagnosa kerja minimal 3 karakter"
|
||||
}
|
||||
if consistsOfOneRepeatedRune(workingDiagnosis) {
|
||||
return validatedLabRegistration{}, "Diagnosa kerja tidak valid"
|
||||
}
|
||||
|
||||
notes, _, message := optionalString(payload, "catatan", 1000)
|
||||
if message != "" {
|
||||
return validatedLabRegistration{}, message
|
||||
}
|
||||
channel, channelPresent, message := optionalString(payload, "channel", 50)
|
||||
if message != "" {
|
||||
return validatedLabRegistration{}, message
|
||||
}
|
||||
if !channelPresent {
|
||||
channel = "CPONE"
|
||||
}
|
||||
channel = strings.ToUpper(strings.TrimSpace(channel))
|
||||
|
||||
return validatedLabRegistration{Params: repository.LabRegistrationParams{
|
||||
MedrecID: medrecID,
|
||||
RegistrationDate: registrationDate,
|
||||
DepartmentID: departmentID,
|
||||
DoctorID: doctorID,
|
||||
SenderDoctorID: senderDoctorID,
|
||||
Referrer: referrer,
|
||||
TariffPeriodID: tariffPeriodID,
|
||||
ClassID: classID,
|
||||
DailyShiftID: dailyShiftID,
|
||||
WorkingDiagnosis: workingDiagnosis,
|
||||
Notes: notes,
|
||||
Channel: channel,
|
||||
}}, ""
|
||||
}
|
||||
|
||||
func requiredString(payload map[string]any, key string, maxLength int, requiredMessage string) (string, string) {
|
||||
value, exists := payload[key]
|
||||
if !exists || isLaravelEmpty(value) {
|
||||
return "", requiredMessage
|
||||
}
|
||||
text, ok := value.(string)
|
||||
if !ok {
|
||||
return "", fmt.Sprintf("The %s must be a string.", validationAttribute(key))
|
||||
}
|
||||
if maxLength > 0 && utf8.RuneCountInString(text) > maxLength {
|
||||
return "", fmt.Sprintf("The %s must not be greater than %d characters.", validationAttribute(key), maxLength)
|
||||
}
|
||||
return text, ""
|
||||
}
|
||||
|
||||
func optionalString(payload map[string]any, key string, maxLength int) (string, bool, string) {
|
||||
value, present := nullableValue(payload, key)
|
||||
if !present {
|
||||
return "", false, ""
|
||||
}
|
||||
text, ok := value.(string)
|
||||
if !ok {
|
||||
return "", false, fmt.Sprintf("The %s must be a string.", validationAttribute(key))
|
||||
}
|
||||
if maxLength > 0 && utf8.RuneCountInString(text) > maxLength {
|
||||
return "", false, fmt.Sprintf("The %s must not be greater than %d characters.", validationAttribute(key), maxLength)
|
||||
}
|
||||
return text, true, ""
|
||||
}
|
||||
|
||||
func requiredInteger(payload map[string]any, key string, requiredMessage string) (int64, string) {
|
||||
value, exists := payload[key]
|
||||
if !exists || isLaravelEmpty(value) {
|
||||
return 0, requiredMessage
|
||||
}
|
||||
parsed, ok := parseInteger(value)
|
||||
if !ok {
|
||||
return 0, fmt.Sprintf("The %s must be an integer.", validationAttribute(key))
|
||||
}
|
||||
return parsed, ""
|
||||
}
|
||||
|
||||
func nullableValue(payload map[string]any, key string) (any, bool) {
|
||||
value, exists := payload[key]
|
||||
if !exists || value == nil || value == "" {
|
||||
return nil, false
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
|
||||
func parseInteger(value any) (int64, bool) {
|
||||
switch typed := value.(type) {
|
||||
case json.Number:
|
||||
if integerValue.MatchString(typed.String()) {
|
||||
parsed, err := strconv.ParseInt(typed.String(), 10, 64)
|
||||
return parsed, err == nil
|
||||
}
|
||||
parsedFloat, err := strconv.ParseFloat(typed.String(), 64)
|
||||
if err != nil || math.Trunc(parsedFloat) != parsedFloat || parsedFloat > math.MaxInt64 || parsedFloat < math.MinInt64 {
|
||||
return 0, false
|
||||
}
|
||||
return int64(parsedFloat), true
|
||||
case string:
|
||||
typed = strings.TrimSpace(typed)
|
||||
if !integerValue.MatchString(typed) || hasInvalidIntegerLeadingZero(typed) {
|
||||
return 0, false
|
||||
}
|
||||
parsed, err := strconv.ParseInt(typed, 10, 64)
|
||||
return parsed, err == nil
|
||||
case bool:
|
||||
if typed {
|
||||
return 1, true
|
||||
}
|
||||
return 0, false
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func hasInvalidIntegerLeadingZero(value string) bool {
|
||||
digits := strings.TrimPrefix(strings.TrimPrefix(value, "+"), "-")
|
||||
return len(digits) > 1 && strings.HasPrefix(digits, "0")
|
||||
}
|
||||
|
||||
func isLaravelEmpty(value any) bool {
|
||||
if value == nil || value == "" {
|
||||
return true
|
||||
}
|
||||
switch typed := value.(type) {
|
||||
case []any:
|
||||
return len(typed) == 0
|
||||
case map[string]any:
|
||||
return len(typed) == 0
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func validDateFormat(value string, location *time.Location) bool {
|
||||
parsed, err := time.ParseInLocation("2006-01-02", value, location)
|
||||
return err == nil && parsed.Format("2006-01-02") == value
|
||||
}
|
||||
|
||||
func consistsOfOneRepeatedRune(value string) bool {
|
||||
var first rune
|
||||
count := 0
|
||||
for _, current := range value {
|
||||
if count == 0 {
|
||||
first = current
|
||||
} else if current != first {
|
||||
return false
|
||||
}
|
||||
count++
|
||||
}
|
||||
return count > 0
|
||||
}
|
||||
|
||||
func validationAttribute(key string) string {
|
||||
return strings.ReplaceAll(key, "_", " ")
|
||||
}
|
||||
|
||||
func writeLabResponse(
|
||||
w http.ResponseWriter,
|
||||
statusCode int,
|
||||
success bool,
|
||||
status string,
|
||||
message string,
|
||||
data *repository.LabRegistrationData,
|
||||
) {
|
||||
WriteJSON(w, statusCode, LabRegistrationResponse{
|
||||
Success: success,
|
||||
Status: status,
|
||||
Message: message,
|
||||
Data: data,
|
||||
})
|
||||
}
|
||||
228
internal/http/lab_registration_test.go
Normal file
228
internal/http/lab_registration_test.go
Normal file
@@ -0,0 +1,228 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"primaya-api/cpone-middleware/internal/repository"
|
||||
)
|
||||
|
||||
type fakeLabRegistrationRepository struct {
|
||||
outcome repository.LabRegistrationOutcome
|
||||
err error
|
||||
params repository.LabRegistrationParams
|
||||
addParams repository.AddLabServicesParams
|
||||
regID string
|
||||
}
|
||||
|
||||
func (f *fakeLabRegistrationRepository) RegisterLab(_ context.Context, params repository.LabRegistrationParams) (repository.LabRegistrationOutcome, error) {
|
||||
f.params = params
|
||||
return f.outcome, f.err
|
||||
}
|
||||
|
||||
func (f *fakeLabRegistrationRepository) AddLabServices(_ context.Context, regID string, params repository.AddLabServicesParams) (repository.LabRegistrationOutcome, error) {
|
||||
f.regID = regID
|
||||
f.addParams = params
|
||||
return f.outcome, f.err
|
||||
}
|
||||
|
||||
func TestRegisterLabValidPayloadAndResponseParity(t *testing.T) {
|
||||
doctorID := "DR01"
|
||||
registrationType := int64(3)
|
||||
repo := &fakeLabRegistrationRepository{outcome: repository.LabRegistrationOutcome{
|
||||
HTTPStatus: http.StatusCreated,
|
||||
Success: true,
|
||||
Status: "registered",
|
||||
Message: "Registrasi laboratorium berhasil disimpan",
|
||||
Data: &repository.LabRegistrationData{
|
||||
Registration: repository.LabRegistration{
|
||||
RegID: "CPLAB2608180001",
|
||||
MEDRECID: "00001234",
|
||||
Tanggal: "2026-08-18 10:11:12",
|
||||
DepartemenID: "LAB",
|
||||
DokterID: &doctorID,
|
||||
JenisPasienID: 0,
|
||||
JenisRegID: ®istrationType,
|
||||
PeriodeTarifID: 10,
|
||||
},
|
||||
Transaction: &repository.LabTransaction{
|
||||
TrxDepartemenID: 99,
|
||||
TrxID: "CPLAB2608180001",
|
||||
GrandTotal: 0,
|
||||
},
|
||||
Layanan: []repository.LabService{},
|
||||
},
|
||||
}}
|
||||
handler := Handler{labRepo: repo}
|
||||
body := `{
|
||||
"medrec_id":"00001234",
|
||||
"tanggal":"2026-08-18",
|
||||
"departemen_id":"LAB",
|
||||
"dokter_id":"DR01",
|
||||
"perujuk":" Klinik A ",
|
||||
"jenis_pasien_id":"0",
|
||||
"periode_tarif_id":"10",
|
||||
"kelas_id":"",
|
||||
"diagnosa_kerja":"Pemeriksaan laboratorium",
|
||||
"catatan":" catatan ",
|
||||
"channel":" cpone-api "
|
||||
}`
|
||||
|
||||
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())
|
||||
}
|
||||
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"
|
||||
if recorder.Body.String() != expected {
|
||||
t.Fatalf("body mismatch\n got: %s\nwant: %s", recorder.Body.String(), expected)
|
||||
}
|
||||
|
||||
if repo.params.MedrecID != "00001234" || repo.params.DepartmentID != "LAB" || repo.params.DoctorID != "DR01" {
|
||||
t.Fatalf("unexpected repository params: %+v", repo.params)
|
||||
}
|
||||
if repo.params.TariffPeriodID != 10 || repo.params.ClassID != "Default" || repo.params.Channel != "CPONE-API" {
|
||||
t.Fatalf("normalization mismatch: %+v", repo.params)
|
||||
}
|
||||
if repo.params.Referrer != " Klinik A " || repo.params.Notes != " catatan " {
|
||||
t.Fatalf("handler must preserve values for repository trimming: %+v", repo.params)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterLabValidationParity(t *testing.T) {
|
||||
baseline := map[string]any{
|
||||
"medrec_id": "00001234",
|
||||
"departemen_id": "LAB",
|
||||
"dokter_id": "DR01",
|
||||
"dokter_pengirim_id": "DR02",
|
||||
"periode_tarif_id": 10,
|
||||
"diagnosa_kerja": "Pemeriksaan laboratorium",
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(map[string]any)
|
||||
message string
|
||||
}{
|
||||
{
|
||||
name: "missing medrec",
|
||||
mutate: func(payload map[string]any) { delete(payload, "medrec_id") },
|
||||
message: "Medrec ID wajib diisi",
|
||||
},
|
||||
{
|
||||
name: "invalid date",
|
||||
mutate: func(payload map[string]any) { payload["tanggal"] = "18-08-2026" },
|
||||
message: "Tanggal registrasi harus format Y-m-d",
|
||||
},
|
||||
{
|
||||
name: "missing sender and referrer",
|
||||
mutate: func(payload map[string]any) {
|
||||
delete(payload, "dokter_pengirim_id")
|
||||
delete(payload, "perujuk")
|
||||
},
|
||||
message: "Dokter pengirim atau perujuk wajib diisi",
|
||||
},
|
||||
{
|
||||
name: "non-general patient",
|
||||
mutate: func(payload map[string]any) { payload["jenis_pasien_id"] = "1" },
|
||||
message: "Registrasi laboratorium API saat ini hanya mendukung pasien umum",
|
||||
},
|
||||
{
|
||||
name: "invalid tariff period type",
|
||||
mutate: func(payload map[string]any) { payload["periode_tarif_id"] = "1.5" },
|
||||
message: "The periode tarif id must be an integer.",
|
||||
},
|
||||
{
|
||||
name: "short diagnosis",
|
||||
mutate: func(payload map[string]any) { payload["diagnosa_kerja"] = "AB" },
|
||||
message: "Diagnosa kerja minimal 3 karakter",
|
||||
},
|
||||
{
|
||||
name: "repeated diagnosis",
|
||||
mutate: func(payload map[string]any) { payload["diagnosa_kerja"] = "---" },
|
||||
message: "Diagnosa kerja tidak valid",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
payload := make(map[string]any, len(baseline))
|
||||
for key, value := range baseline {
|
||||
payload[key] = value
|
||||
}
|
||||
test.mutate(payload)
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
handler := Handler{labRepo: &fakeLabRegistrationRepository{}}
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/cpone/patients/lab-registration", strings.NewReader(string(body)))
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.RegisterLab(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("status = %d; body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
expected := `{"success":false,"status":"validation_error","message":` + strconvQuote(test.message) + `,"data":null}` + "\n"
|
||||
if recorder.Body.String() != expected {
|
||||
t.Fatalf("body mismatch\n got: %s\nwant: %s", recorder.Body.String(), expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterLabRepositoryErrorsMatchLaravelEnvelope(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
statusCode int
|
||||
status string
|
||||
message string
|
||||
}{
|
||||
{
|
||||
name: "domain validation",
|
||||
err: repository.ValidationError{Message: "Departemen tujuan bukan departemen laboratorium aktif"},
|
||||
statusCode: http.StatusUnprocessableEntity,
|
||||
status: "validation_error",
|
||||
message: "Departemen tujuan bukan departemen laboratorium aktif",
|
||||
},
|
||||
{
|
||||
name: "database error",
|
||||
err: errors.New("database unavailable"),
|
||||
statusCode: http.StatusInternalServerError,
|
||||
status: "error",
|
||||
message: "database unavailable",
|
||||
},
|
||||
}
|
||||
|
||||
const validBody = `{"medrec_id":"00001234","departemen_id":"LAB","dokter_id":"DR01","dokter_pengirim_id":"DR02","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}}
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/cpone/patients/lab-registration", strings.NewReader(validBody))
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.RegisterLab(recorder, request)
|
||||
|
||||
if recorder.Code != test.statusCode {
|
||||
t.Fatalf("status = %d; body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
expected := `{"success":false,"status":` + strconvQuote(test.status) + `,"message":` + strconvQuote(test.message) + `,"data":null}` + "\n"
|
||||
if recorder.Body.String() != expected {
|
||||
t.Fatalf("body mismatch\n got: %s\nwant: %s", recorder.Body.String(), expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func strconvQuote(value string) string {
|
||||
encoded, _ := json.Marshal(value)
|
||||
return string(encoded)
|
||||
}
|
||||
215
internal/http/lab_services.go
Normal file
215
internal/http/lab_services.go
Normal file
@@ -0,0 +1,215 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"primaya-api/cpone-middleware/internal/repository"
|
||||
)
|
||||
|
||||
func (h Handler) AddLabServices(w http.ResponseWriter, r *http.Request) {
|
||||
payload, _ := decodeJSONObject(r)
|
||||
params, validationMessage := validateAddLabServices(payload)
|
||||
if validationMessage != "" {
|
||||
writeLabResponse(w, http.StatusUnprocessableEntity, false, "validation_error", validationMessage, nil)
|
||||
return
|
||||
}
|
||||
|
||||
outcome, err := h.labRepo.AddLabServices(r.Context(), r.PathValue("regId"), params)
|
||||
if err != nil {
|
||||
if validationErr, ok := asValidationError(err); ok {
|
||||
writeLabResponse(w, http.StatusUnprocessableEntity, false, "validation_error", validationErr, nil)
|
||||
return
|
||||
}
|
||||
writeLabResponse(w, http.StatusInternalServerError, false, "error", err.Error(), nil)
|
||||
return
|
||||
}
|
||||
|
||||
writeLabResponse(w, outcome.HTTPStatus, outcome.Success, outcome.Status, outcome.Message, outcome.Data)
|
||||
}
|
||||
|
||||
func validateAddLabServices(payload map[string]any) (repository.AddLabServicesParams, string) {
|
||||
value, exists := payload["layanan"]
|
||||
if !exists || isLaravelEmpty(value) {
|
||||
return repository.AddLabServicesParams{}, "Minimal satu layanan wajib diisi"
|
||||
}
|
||||
items, ok := value.([]any)
|
||||
if !ok {
|
||||
return repository.AddLabServicesParams{}, "The layanan must be an array."
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return repository.AddLabServicesParams{}, "Minimal satu layanan wajib diisi"
|
||||
}
|
||||
|
||||
services := make([]repository.RequestedLabService, len(items))
|
||||
seenIDs := make(map[string]struct{}, len(items))
|
||||
for index, rawItem := range items {
|
||||
item, _ := rawItem.(map[string]any)
|
||||
serviceID, message := requiredNestedString(item, "layanan_id", index, 50, "Layanan ID wajib diisi")
|
||||
if message != "" {
|
||||
return repository.AddLabServicesParams{}, message
|
||||
}
|
||||
if _, duplicate := seenIDs[serviceID]; duplicate {
|
||||
return repository.AddLabServicesParams{}, "Layanan yang sama tidak boleh dikirim lebih dari satu kali"
|
||||
}
|
||||
seenIDs[serviceID] = struct{}{}
|
||||
services[index].LayananID = serviceID
|
||||
}
|
||||
|
||||
for index, rawItem := range items {
|
||||
item, _ := rawItem.(map[string]any)
|
||||
services[index].Jumlah = 1
|
||||
if value, present := nullableValue(item, "jumlah"); present {
|
||||
quantity, valid := parseInteger(value)
|
||||
if !valid {
|
||||
return repository.AddLabServicesParams{}, nestedMessage(index, "jumlah", "must be an integer")
|
||||
}
|
||||
if quantity < 1 {
|
||||
return repository.AddLabServicesParams{}, nestedMessage(index, "jumlah", "must be at least 1")
|
||||
}
|
||||
if quantity > 100 {
|
||||
return repository.AddLabServicesParams{}, nestedMessage(index, "jumlah", "must not be greater than 100")
|
||||
}
|
||||
services[index].Jumlah = int(quantity)
|
||||
}
|
||||
}
|
||||
|
||||
for index, rawItem := range items {
|
||||
item, _ := rawItem.(map[string]any)
|
||||
if value, present := nullableValue(item, "cito"); present {
|
||||
cito, valid := parseLaravelBoolean(value)
|
||||
if !valid {
|
||||
return repository.AddLabServicesParams{}, nestedMessage(index, "cito", "field must be true or false")
|
||||
}
|
||||
services[index].Cito = cito
|
||||
}
|
||||
}
|
||||
|
||||
for index, rawItem := range items {
|
||||
item, _ := rawItem.(map[string]any)
|
||||
if value, present := nullableValue(item, "markup_cito"); present {
|
||||
markup, valid := parseNumeric(value)
|
||||
if !valid {
|
||||
return repository.AddLabServicesParams{}, nestedMessage(index, "markup_cito", "must be a number")
|
||||
}
|
||||
if markup < 0 {
|
||||
return repository.AddLabServicesParams{}, nestedMessage(index, "markup_cito", "must be at least 0")
|
||||
}
|
||||
if markup > 100 {
|
||||
return repository.AddLabServicesParams{}, nestedMessage(index, "markup_cito", "must not be greater than 100")
|
||||
}
|
||||
services[index].MarkupCito = markup
|
||||
}
|
||||
if !services[index].Cito {
|
||||
services[index].MarkupCito = 0
|
||||
}
|
||||
}
|
||||
|
||||
for index, rawItem := range items {
|
||||
item, _ := rawItem.(map[string]any)
|
||||
notes, _, message := optionalNestedString(item, "catatan", index, 500)
|
||||
if message != "" {
|
||||
return repository.AddLabServicesParams{}, message
|
||||
}
|
||||
services[index].Catatan = notes
|
||||
}
|
||||
|
||||
channel, channelPresent, message := optionalString(payload, "channel", 50)
|
||||
if message != "" {
|
||||
return repository.AddLabServicesParams{}, message
|
||||
}
|
||||
if !channelPresent {
|
||||
channel = "CPONE"
|
||||
}
|
||||
channel = strings.ToUpper(strings.TrimSpace(channel))
|
||||
|
||||
return repository.AddLabServicesParams{Layanan: services, Channel: channel}, ""
|
||||
}
|
||||
|
||||
func requiredNestedString(item map[string]any, key string, index int, maxLength int, requiredMessage string) (string, string) {
|
||||
value, exists := item[key]
|
||||
if !exists || isLaravelEmpty(value) {
|
||||
return "", requiredMessage
|
||||
}
|
||||
text, ok := value.(string)
|
||||
if !ok {
|
||||
return "", nestedMessage(index, key, "must be a string")
|
||||
}
|
||||
if utf8.RuneCountInString(text) > maxLength {
|
||||
return "", nestedMessage(index, key, fmt.Sprintf("must not be greater than %d characters", maxLength))
|
||||
}
|
||||
return text, ""
|
||||
}
|
||||
|
||||
func optionalNestedString(item map[string]any, key string, index int, maxLength int) (string, bool, string) {
|
||||
value, present := nullableValue(item, key)
|
||||
if !present {
|
||||
return "", false, ""
|
||||
}
|
||||
text, ok := value.(string)
|
||||
if !ok {
|
||||
return "", false, nestedMessage(index, key, "must be a string")
|
||||
}
|
||||
if utf8.RuneCountInString(text) > maxLength {
|
||||
return "", false, nestedMessage(index, key, fmt.Sprintf("must not be greater than %d characters", maxLength))
|
||||
}
|
||||
return text, true, ""
|
||||
}
|
||||
|
||||
func nestedMessage(index int, key string, ruleMessage string) string {
|
||||
attribute := fmt.Sprintf("layanan.%d.%s", index, strings.ReplaceAll(key, "_", " "))
|
||||
if strings.HasPrefix(ruleMessage, "field ") {
|
||||
return fmt.Sprintf("The %s %s.", attribute, ruleMessage)
|
||||
}
|
||||
return fmt.Sprintf("The %s %s.", attribute, ruleMessage)
|
||||
}
|
||||
|
||||
func parseLaravelBoolean(value any) (bool, bool) {
|
||||
switch typed := value.(type) {
|
||||
case bool:
|
||||
return typed, true
|
||||
case json.Number:
|
||||
if typed.String() == "1" {
|
||||
return true, true
|
||||
}
|
||||
if typed.String() == "0" {
|
||||
return false, true
|
||||
}
|
||||
case string:
|
||||
if typed == "1" {
|
||||
return true, true
|
||||
}
|
||||
if typed == "0" {
|
||||
return false, true
|
||||
}
|
||||
}
|
||||
return false, false
|
||||
}
|
||||
|
||||
func parseNumeric(value any) (float64, bool) {
|
||||
var text string
|
||||
switch typed := value.(type) {
|
||||
case json.Number:
|
||||
text = typed.String()
|
||||
case string:
|
||||
text = strings.TrimSpace(typed)
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
parsed, err := strconv.ParseFloat(text, 64)
|
||||
return parsed, err == nil && !math.IsInf(parsed, 0) && !math.IsNaN(parsed)
|
||||
}
|
||||
|
||||
func asValidationError(err error) (string, bool) {
|
||||
var validationErr repository.ValidationError
|
||||
if errors.As(err, &validationErr) {
|
||||
return validationErr.Message, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
168
internal/http/lab_services_test.go
Normal file
168
internal/http/lab_services_test.go
Normal file
@@ -0,0 +1,168 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"primaya-api/cpone-middleware/internal/repository"
|
||||
)
|
||||
|
||||
func TestAddLabServicesValidPayloadAndResponseParity(t *testing.T) {
|
||||
doctorID := "DR01"
|
||||
registrationType := int64(3)
|
||||
added := []string{"6413"}
|
||||
skipped := []string{"6414"}
|
||||
repo := &fakeLabRegistrationRepository{outcome: repository.LabRegistrationOutcome{
|
||||
HTTPStatus: http.StatusCreated,
|
||||
Success: true,
|
||||
Status: "services_added",
|
||||
Message: "Layanan laboratorium berhasil ditambahkan",
|
||||
Data: &repository.LabRegistrationData{
|
||||
Registration: repository.LabRegistration{
|
||||
RegID: "CPLAB2608180001",
|
||||
MEDRECID: "00001234",
|
||||
Tanggal: "2026-08-18 10:11:12",
|
||||
DepartemenID: "LAB",
|
||||
DokterID: &doctorID,
|
||||
JenisPasienID: 0,
|
||||
JenisRegID: ®istrationType,
|
||||
PeriodeTarifID: 10,
|
||||
},
|
||||
Transaction: &repository.LabTransaction{
|
||||
TrxDepartemenID: 99,
|
||||
TrxID: "CPLAB2608180001",
|
||||
GrandTotal: 125000,
|
||||
},
|
||||
Layanan: []repository.LabService{},
|
||||
AddedLayananIDs: &added,
|
||||
SkippedLayananIDs: &skipped,
|
||||
},
|
||||
}}
|
||||
handler := Handler{labRepo: repo}
|
||||
body := `{
|
||||
"layanan":[
|
||||
{"layanan_id":"6413","jumlah":"2","cito":true,"markup_cito":"10.5","catatan":" urgent "},
|
||||
{"layanan_id":"6414","cito":"0"}
|
||||
],
|
||||
"channel":" cpone-api "
|
||||
}`
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/cpone/patients/lab-registration/CPLAB2608180001/services", strings.NewReader(body))
|
||||
request.SetPathValue("regId", "CPLAB2608180001")
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler.AddLabServices(recorder, request)
|
||||
|
||||
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"
|
||||
if recorder.Body.String() != expected {
|
||||
t.Fatalf("body mismatch\n got: %s\nwant: %s", recorder.Body.String(), expected)
|
||||
}
|
||||
if repo.regID != "CPLAB2608180001" || repo.addParams.Channel != "CPONE-API" {
|
||||
t.Fatalf("unexpected repository call: regID=%q params=%+v", repo.regID, repo.addParams)
|
||||
}
|
||||
if len(repo.addParams.Layanan) != 2 {
|
||||
t.Fatalf("services count = %d", len(repo.addParams.Layanan))
|
||||
}
|
||||
first := repo.addParams.Layanan[0]
|
||||
if first.LayananID != "6413" || first.Jumlah != 2 || !first.Cito || first.MarkupCito != 10.5 || first.Catatan != " urgent " {
|
||||
t.Fatalf("unexpected first service: %+v", first)
|
||||
}
|
||||
second := repo.addParams.Layanan[1]
|
||||
if second.Jumlah != 1 || second.Cito || second.MarkupCito != 0 {
|
||||
t.Fatalf("unexpected defaults: %+v", second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddLabServicesValidationParity(t *testing.T) {
|
||||
baseline := map[string]any{
|
||||
"layanan": []any{
|
||||
map[string]any{"layanan_id": "6413"},
|
||||
},
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(map[string]any)
|
||||
message string
|
||||
}{
|
||||
{
|
||||
name: "missing services",
|
||||
mutate: func(payload map[string]any) { delete(payload, "layanan") },
|
||||
message: "Minimal satu layanan wajib diisi",
|
||||
},
|
||||
{
|
||||
name: "not an array",
|
||||
mutate: func(payload map[string]any) { payload["layanan"] = "6413" },
|
||||
message: "The layanan must be an array.",
|
||||
},
|
||||
{
|
||||
name: "missing service id",
|
||||
mutate: func(payload map[string]any) {
|
||||
payload["layanan"] = []any{map[string]any{}}
|
||||
},
|
||||
message: "Layanan ID wajib diisi",
|
||||
},
|
||||
{
|
||||
name: "duplicate service",
|
||||
mutate: func(payload map[string]any) {
|
||||
payload["layanan"] = []any{
|
||||
map[string]any{"layanan_id": "6413"},
|
||||
map[string]any{"layanan_id": "6413"},
|
||||
}
|
||||
},
|
||||
message: "Layanan yang sama tidak boleh dikirim lebih dari satu kali",
|
||||
},
|
||||
{
|
||||
name: "invalid quantity",
|
||||
mutate: func(payload map[string]any) {
|
||||
payload["layanan"] = []any{map[string]any{"layanan_id": "6413", "jumlah": 0}}
|
||||
},
|
||||
message: "The layanan.0.jumlah must be at least 1.",
|
||||
},
|
||||
{
|
||||
name: "invalid cito",
|
||||
mutate: func(payload map[string]any) {
|
||||
payload["layanan"] = []any{map[string]any{"layanan_id": "6413", "cito": "true"}}
|
||||
},
|
||||
message: "The layanan.0.cito field must be true or false.",
|
||||
},
|
||||
{
|
||||
name: "invalid markup",
|
||||
mutate: func(payload map[string]any) {
|
||||
payload["layanan"] = []any{map[string]any{"layanan_id": "6413", "markup_cito": 101}}
|
||||
},
|
||||
message: "The layanan.0.markup cito must not be greater than 100.",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
payload := make(map[string]any, len(baseline))
|
||||
for key, value := range baseline {
|
||||
payload[key] = value
|
||||
}
|
||||
test.mutate(payload)
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
handler := Handler{labRepo: &fakeLabRegistrationRepository{}}
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/cpone/patients/lab-registration/CPLAB2608180001/services", strings.NewReader(string(body)))
|
||||
request.SetPathValue("regId", "CPLAB2608180001")
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.AddLabServices(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("status = %d; body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
expected := `{"success":false,"status":"validation_error","message":` + strconvQuote(test.message) + `,"data":null}` + "\n"
|
||||
if recorder.Body.String() != expected {
|
||||
t.Fatalf("body mismatch\n got: %s\nwant: %s", recorder.Body.String(), expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ type MappingHargaData struct {
|
||||
DetailLayanan []repository.TarifLayanan `json:"DetailLayanan"`
|
||||
}
|
||||
|
||||
func WriteJSON(w http.ResponseWriter, statusCode int, payload Response) {
|
||||
func WriteJSON(w http.ResponseWriter, statusCode int, payload any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(statusCode)
|
||||
_ = json.NewEncoder(w).Encode(payload)
|
||||
|
||||
Reference in New Issue
Block a user