From 21196d82276116247552fe90f7c4b670bb7029c0 Mon Sep 17 00:00:00 2001 From: Tubagus Fajri Date: Wed, 19 Aug 2026 10:41:07 +0700 Subject: [PATCH] feat: add lab result endpoint --- cmd/server/main.go | 1 + internal/http/handler.go | 14 +- internal/http/lab_result.go | 58 ++++ internal/http/lab_result_test.go | 126 +++++++ internal/repository/lab_result.go | 435 +++++++++++++++++++++++++ internal/repository/lab_result_test.go | 47 +++ 6 files changed, 677 insertions(+), 4 deletions(-) create mode 100644 internal/http/lab_result.go create mode 100644 internal/http/lab_result_test.go create mode 100644 internal/repository/lab_result.go create mode 100644 internal/repository/lab_result_test.go diff --git a/cmd/server/main.go b/cmd/server/main.go index f9fb863..7909e35 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -48,6 +48,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("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") w.WriteHeader(http.StatusOK) diff --git a/internal/http/handler.go b/internal/http/handler.go index daa83b5..ed533aa 100644 --- a/internal/http/handler.go +++ b/internal/http/handler.go @@ -25,20 +25,26 @@ type PatientRepository interface { StoreMedrec(ctx context.Context, params repository.MedrecParams) (repository.MedrecOutcome, error) } +type LabResultRepository interface { + GetLabResult(ctx context.Context, labNumber string) (repository.LabResultOutcome, error) +} + type Handler struct { - layananRepo LayananRepository - labRepo LabRegistrationRepository - patientRepo PatientRepository + layananRepo LayananRepository + labRepo LabRegistrationRepository + patientRepo PatientRepository + labResultRepo LabResultRepository } type CPONERepository interface { LayananRepository LabRegistrationRepository PatientRepository + LabResultRepository } func NewHandler(repo CPONERepository) Handler { - return Handler{layananRepo: repo, labRepo: repo, patientRepo: repo} + return Handler{layananRepo: repo, labRepo: repo, patientRepo: repo, labResultRepo: repo} } func (h Handler) MappingHarga(w http.ResponseWriter, r *http.Request) { diff --git a/internal/http/lab_result.go b/internal/http/lab_result.go new file mode 100644 index 0000000..2b40cdb --- /dev/null +++ b/internal/http/lab_result.go @@ -0,0 +1,58 @@ +package http + +import ( + "errors" + "net/http" + "regexp" + "strings" + "unicode/utf8" + + "primaya-api/cpone-middleware/internal/repository" +) + +var validLabNumber = regexp.MustCompile(`^[A-Za-z0-9-]+$`) + +type LabResultResponse struct { + Success bool `json:"success"` + Status string `json:"status"` + Message string `json:"message"` + Data *repository.LabResultData `json:"data"` +} + +func (h Handler) GetLabResult(w http.ResponseWriter, r *http.Request) { + labNumber := strings.TrimSpace(r.PathValue("labNumber")) + if labNumber == "" { + writeLabResultResponse(w, http.StatusUnprocessableEntity, false, "validation_error", "Nomor laboratorium wajib diisi", nil) + return + } + if utf8.RuneCountInString(labNumber) > 50 { + writeLabResultResponse(w, http.StatusUnprocessableEntity, false, "validation_error", "Nomor laboratorium maksimal 50 karakter", nil) + return + } + if !validLabNumber.MatchString(labNumber) { + writeLabResultResponse(w, http.StatusUnprocessableEntity, false, "validation_error", "Nomor laboratorium hanya boleh berisi huruf, angka, dan tanda hubung", nil) + return + } + + outcome, err := h.labResultRepo.GetLabResult(r.Context(), strings.ToUpper(labNumber)) + if err != nil { + var validationErr repository.ValidationError + if errors.As(err, &validationErr) { + writeLabResultResponse(w, http.StatusUnprocessableEntity, false, "validation_error", validationErr.Message, nil) + return + } + writeLabResultResponse(w, http.StatusInternalServerError, false, "error", err.Error(), nil) + return + } + + writeLabResultResponse(w, outcome.HTTPStatus, outcome.Success, outcome.Status, outcome.Message, outcome.Data) +} + +func writeLabResultResponse(w http.ResponseWriter, statusCode int, success bool, status, message string, data *repository.LabResultData) { + WriteJSON(w, statusCode, LabResultResponse{ + Success: success, + Status: status, + Message: message, + Data: data, + }) +} diff --git a/internal/http/lab_result_test.go b/internal/http/lab_result_test.go new file mode 100644 index 0000000..078c8f8 --- /dev/null +++ b/internal/http/lab_result_test.go @@ -0,0 +1,126 @@ +package http + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "primaya-api/cpone-middleware/internal/repository" +) + +type fakeLabResultRepository struct { + outcome repository.LabResultOutcome + err error + labNumber string +} + +func (f *fakeLabResultRepository) GetLabResult(_ context.Context, labNumber string) (repository.LabResultOutcome, error) { + f.labNumber = labNumber + return f.outcome, f.err +} + +func TestGetLabResultResponseParity(t *testing.T) { + verifiedAt := "2026-08-19 10:00:00" + name := "Hemoglobin" + result := "14.2" + reference := "13.0 - 17.0" + unit := "g/dL" + groupID := "HEMATOLOGI" + groupName := "Hematologi" + repo := &fakeLabResultRepository{outcome: repository.LabResultOutcome{ + HTTPStatus: http.StatusOK, + Success: true, + Status: "lab_result_found", + Message: "Hasil laboratorium ditemukan", + Data: &repository.LabResultData{ + Lab: repository.LabResultHeader{ + LabNumber: "CPLAB2608190001", TrxDepartemenID: 6103991, + Status: "authorized", VerifiedAt: &verifiedAt, + }, + Groups: []repository.LabResultGroup{{ + GroupID: &groupID, GroupName: &groupName, + Items: []repository.LabResultItem{{ + TrxLayananID: 123456, ServiceID: "HB", Name: &name, Result: &result, + ReferenceValue: &reference, Unit: &unit, CriticalFlag: "", + }}, + }}, + }, + }} + handler := Handler{labResultRepo: repo} + request := httptest.NewRequest(http.MethodGet, "/api/cpone/patients/lab-results/cplab2608190001", nil) + request.SetPathValue("labNumber", "cplab2608190001") + recorder := httptest.NewRecorder() + + handler.GetLabResult(recorder, request) + + if recorder.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String()) + } + if repo.labNumber != "CPLAB2608190001" { + t.Fatalf("lab number=%q", repo.labNumber) + } + const expected = `{"success":true,"status":"lab_result_found","message":"Hasil laboratorium ditemukan","data":{"lab":{"lab_number":"CPLAB2608190001","trx_departemen_id":6103991,"registration_id":null,"tanggal":null,"status":"authorized","verified_at":"2026-08-19 10:00:00","authorized_at":null,"catatan":null},"patient":{"medrec_id":null,"nama":null,"tanggal_lahir":null,"jenis_kelamin":null},"doctor":{"penanggung_jawab":null,"pengirim":null},"groups":[{"group_id":"HEMATOLOGI","group_name":"Hematologi","items":[{"trx_layanan_id":123456,"service_id":"HB","service_code":null,"name":"Hemoglobin","result":"14.2","reference_value":"13.0 - 17.0","unit":"g/dL","critical_flag":"","value_type":null,"note":null}]}]}}` + "\n" + if recorder.Body.String() != expected { + t.Fatalf("body mismatch\n got: %s\nwant: %s", recorder.Body.String(), expected) + } +} + +func TestGetLabResultValidationParity(t *testing.T) { + tests := []struct { + name, labNumber, message string + }{ + {name: "invalid characters", labNumber: "CPLAB_260819", message: "Nomor laboratorium hanya boleh berisi huruf, angka, dan tanda hubung"}, + {name: "too long", labNumber: "ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890ABCDEFGHIJKLMNO", message: "Nomor laboratorium maksimal 50 karakter"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + handler := Handler{labResultRepo: &fakeLabResultRepository{}} + request := httptest.NewRequest(http.MethodGet, "/api/cpone/patients/lab-results/"+test.labNumber, nil) + request.SetPathValue("labNumber", test.labNumber) + recorder := httptest.NewRecorder() + handler.GetLabResult(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 TestGetLabResultForwardsDomainAndDatabaseOutcomes(t *testing.T) { + tests := []struct { + name string + repo *fakeLabResultRepository + statusCode int + expected string + }{ + { + name: "not found", + repo: &fakeLabResultRepository{outcome: repository.LabResultOutcome{HTTPStatus: 404, Status: "lab_result_not_found", Message: "Hasil laboratorium tidak ditemukan"}}, + statusCode: 404, + expected: `{"success":false,"status":"lab_result_not_found","message":"Hasil laboratorium tidak ditemukan","data":null}` + "\n", + }, + { + name: "database error", repo: &fakeLabResultRepository{err: errors.New("database unavailable")}, + statusCode: 500, + expected: `{"success":false,"status":"error","message":"database unavailable","data":null}` + "\n", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + handler := Handler{labResultRepo: test.repo} + request := httptest.NewRequest(http.MethodGet, "/api/cpone/patients/lab-results/CPLAB2608190001", nil) + request.SetPathValue("labNumber", "CPLAB2608190001") + recorder := httptest.NewRecorder() + handler.GetLabResult(recorder, request) + if recorder.Code != test.statusCode || recorder.Body.String() != test.expected { + t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String()) + } + }) + } +} diff --git a/internal/repository/lab_result.go b/internal/repository/lab_result.go new file mode 100644 index 0000000..f428c53 --- /dev/null +++ b/internal/repository/lab_result.go @@ -0,0 +1,435 @@ +package repository + +import ( + "context" + "database/sql" + "fmt" + "strings" + "time" +) + +type LabResultOutcome struct { + HTTPStatus int + Success bool + Status string + Message string + Data *LabResultData +} + +type LabResultData struct { + Lab LabResultHeader `json:"lab"` + Patient LabResultPatient `json:"patient"` + Doctor LabResultDoctor `json:"doctor"` + Groups []LabResultGroup `json:"groups"` +} + +type LabResultHeader struct { + LabNumber string `json:"lab_number"` + TrxDepartemenID int64 `json:"trx_departemen_id"` + RegistrationID *string `json:"registration_id"` + Tanggal *string `json:"tanggal"` + Status string `json:"status"` + VerifiedAt *string `json:"verified_at"` + AuthorizedAt *string `json:"authorized_at"` + Catatan *string `json:"catatan"` +} + +type LabResultPatient struct { + MedrecID *string `json:"medrec_id"` + Nama *string `json:"nama"` + TanggalLahir *string `json:"tanggal_lahir"` + JenisKelamin *string `json:"jenis_kelamin"` +} + +type LabResultDoctor struct { + PenanggungJawab *string `json:"penanggung_jawab"` + Pengirim *string `json:"pengirim"` +} + +type LabResultGroup struct { + GroupID *string `json:"group_id"` + GroupName *string `json:"group_name"` + Items []LabResultItem `json:"items"` +} + +type LabResultItem struct { + TrxLayananID int64 `json:"trx_layanan_id"` + ServiceID string `json:"service_id"` + ServiceCode *string `json:"service_code"` + Name *string `json:"name"` + Result *string `json:"result"` + ReferenceValue *string `json:"reference_value"` + Unit *string `json:"unit"` + CriticalFlag string `json:"critical_flag"` + ValueType *string `json:"value_type"` + Note *string `json:"note"` +} + +type labResultRecord struct { + TrxDepartemenID int64 + TrxID string + RegID sql.NullString + Tanggal sql.NullString + Authorized sql.NullString + AuthorizedAt sql.NullString + Catatan sql.NullString + MedrecID sql.NullString + Referrer sql.NullString + PatientName sql.NullString + BirthDate sql.NullString + Gender sql.NullString + ResponsibleDoctor sql.NullString + SenderDoctor sql.NullString +} + +type rawLabResultItem struct { + LabResultItem + GroupID sql.NullString + GroupName sql.NullString + FlagHide sql.NullString + DisplayResult sql.NullFloat64 + UnitSnapshot sql.NullString + UnitName sql.NullString + VerifiedAt sql.NullString + AnalyzerID sql.NullString + MethodID sql.NullString +} + +func (r MySQLLayananRepository) GetLabResult(ctx context.Context, labNumber string) (LabResultOutcome, error) { + lab, found, err := r.findLabResult(ctx, labNumber) + if err != nil { + return LabResultOutcome{}, err + } + if !found { + return labResultOutcome(404, false, "lab_result_not_found", "Hasil laboratorium tidak ditemukan", nil), nil + } + if lab.Authorized.String != "Y" { + return labResultOutcome(409, false, "lab_result_not_released", "Hasil laboratorium belum diotorisasi", nil), nil + } + + items, err := r.findVerifiedLabResultItems(ctx, lab) + if err != nil { + return LabResultOutcome{}, err + } + groups := make([]LabResultGroup, 0) + groupIndexes := make(map[string]int) + var verifiedAt *string + for _, item := range items { + if item.VerifiedAt.Valid && (verifiedAt == nil || item.VerifiedAt.String > *verifiedAt) { + value := item.VerifiedAt.String + verifiedAt = &value + } + key := "__ungrouped" + if item.GroupID.Valid { + key = item.GroupID.String + } + index, exists := groupIndexes[key] + if !exists { + index = len(groups) + groupIndexes[key] = index + groups = append(groups, LabResultGroup{ + GroupID: labNullStringPtr(item.GroupID), GroupName: labNullStringPtr(item.GroupName), Items: make([]LabResultItem, 0), + }) + } + groups[index].Items = append(groups[index].Items, item.LabResultItem) + } + + sender := labNullStringPtr(lab.SenderDoctor) + if sender == nil { + sender = labNullStringPtr(lab.Referrer) + } + data := &LabResultData{ + Lab: LabResultHeader{ + LabNumber: lab.TrxID, TrxDepartemenID: lab.TrxDepartemenID, + RegistrationID: labNullStringPtr(lab.RegID), Tanggal: labNullStringPtr(lab.Tanggal), + Status: "authorized", VerifiedAt: verifiedAt, + AuthorizedAt: labNullStringPtr(lab.AuthorizedAt), Catatan: labNullStringPtr(lab.Catatan), + }, + Patient: LabResultPatient{ + MedrecID: labNullStringPtr(lab.MedrecID), Nama: labNullStringPtr(lab.PatientName), + TanggalLahir: labNullStringPtr(lab.BirthDate), JenisKelamin: labNullStringPtr(lab.Gender), + }, + Doctor: LabResultDoctor{PenanggungJawab: labNullStringPtr(lab.ResponsibleDoctor), Pengirim: sender}, + Groups: groups, + } + return labResultOutcome(200, true, "lab_result_found", "Hasil laboratorium ditemukan", data), nil +} + +func (r MySQLLayananRepository) findLabResult(ctx context.Context, labNumber string) (labResultRecord, bool, error) { + row := r.db.QueryRowContext(ctx, ` + SELECT td.TrxDepartemenID, td.TrxID, td.RegID, + DATE_FORMAT(td.Tanggal, '%Y-%m-%d %H:%i:%s'), td.DiOtorisasi, + DATE_FORMAT(td.DiOtorisasiTanggal, '%Y-%m-%d %H:%i:%s'), td.Catatan, + rp.MEDRECID, rp.Perujuk, mr.Nama, DATE_FORMAT(mr.TanggalLahir, '%Y-%m-%d'), mr.Kelamin, + dpj.Nama, dpk.Nama + FROM trxdepartemen AS td + INNER JOIN departemen AS dep ON dep.DepartemenID = td.DepartemenID + LEFT JOIN regpas AS rp ON rp.RegID = td.RegID + LEFT JOIN medrec AS mr ON mr.MEDRECID = rp.MEDRECID + LEFT JOIN dokter AS dpj ON dpj.DokterID = td.DokterID + LEFT JOIN dokter AS dpk ON dpk.DokterID = td.Dokter2 + WHERE td.TrxID = ? AND td.NA = 'N' AND dep.NamaModulExternal = 'LABORATORIUM' + LIMIT 1`, labNumber) + + var lab labResultRecord + err := row.Scan( + &lab.TrxDepartemenID, &lab.TrxID, &lab.RegID, &lab.Tanggal, &lab.Authorized, + &lab.AuthorizedAt, &lab.Catatan, &lab.MedrecID, &lab.Referrer, &lab.PatientName, + &lab.BirthDate, &lab.Gender, &lab.ResponsibleDoctor, &lab.SenderDoctor, + ) + if err == sql.ErrNoRows { + return labResultRecord{}, false, nil + } + return lab, err == nil, err +} + +func (r MySQLLayananRepository) findVerifiedLabResultItems(ctx context.Context, lab labResultRecord) ([]rawLabResultItem, error) { + processColumns, err := r.tableColumns(ctx, "trxlayanan_proses") + if err != nil { + return nil, err + } + analyzerColumn := findColumn(processColumns, "AnalyzerID", "Lab_AnalyzerID") + methodColumn := findColumn(processColumns, "Lab_MethodID", "MethodID", "MetodeID") + processColumn := findColumn(processColumns, "Proses") + analyzerSelect := "NULL" + if analyzerColumn != "" { + analyzerSelect = "tlp.`" + analyzerColumn + "`" + } + methodSelect := "NULL" + if methodColumn != "" { + methodSelect = "tlp.`" + methodColumn + "`" + } + processJoin := "" + if processColumn != "" { + processJoin = " AND tlp.`" + processColumn + "` = 'Y'" + } + + query := fmt.Sprintf(` + SELECT tl.TrxLayananID, tl.LayananID, ml.LayananKode, ml.Nama, + ml.GroupLayananID, gl.Nama, lml.FlagHideCetak, lts.Display_Hasil, + tll.Nilai, tll.NilaiNormalKet, tll.Satuan, sl.Nama, + tll.CriticalFlag, tll.CatatanHasil, tll.TipeNilai, + DATE_FORMAT(tll.HasilDiverifikasiTanggal, '%%Y-%%m-%%d %%H:%%i:%%s'), + %s, %s + FROM trxlayanan AS tl + INNER JOIN trxlayananlaboratorium AS tll ON tll.TrxLayananID = tl.TrxLayananID + INNER JOIN masterlayanan AS ml ON ml.LayananID = tl.LayananID + LEFT JOIN grouplayanan AS gl ON gl.GroupLayananID = ml.GroupLayananID + LEFT JOIN lab_masterlayanan AS lml ON lml.LayananID = tl.LayananID + LEFT JOIN lab_trxlayanan_setup AS lts ON lts.TrxLayananID = tl.TrxLayananID + LEFT JOIN trxlayanan_penunjang AS tlp_header ON tlp_header.TrxLayananID = tl.TrxLayananID + LEFT JOIN trxlayanan_proses AS tlp ON tlp.TrxLayananID = tl.TrxLayananID%s + LEFT JOIN satuanlab AS sl ON sl.SatuanID = tll.Satuan + WHERE tl.TrxDepartemenID = ? AND tl.NA = 'N' AND tl.HeaderDetail > 0 + AND tll.HasilDiverifikasi = 'Y' + ORDER BY tl.PaketID, tlp_header.LayananIDHeader, tl.Urutan, tl.LayananID, tl.TrxLayananID`, + analyzerSelect, methodSelect, processJoin, + ) + rows, err := r.db.QueryContext(ctx, query, lab.TrxDepartemenID) + if err != nil { + return nil, err + } + result := make([]rawLabResultItem, 0) + seen := make(map[int64]struct{}) + for rows.Next() { + var item rawLabResultItem + var serviceCode, name, resultValue, referenceValue, criticalFlag, note, valueType sql.NullString + err := rows.Scan( + &item.TrxLayananID, &item.ServiceID, &serviceCode, &name, + &item.GroupID, &item.GroupName, &item.FlagHide, &item.DisplayResult, + &resultValue, &referenceValue, &item.UnitSnapshot, &item.UnitName, + &criticalFlag, ¬e, &valueType, &item.VerifiedAt, &item.AnalyzerID, &item.MethodID, + ) + if err != nil { + return nil, err + } + if _, duplicate := seen[item.TrxLayananID]; duplicate { + continue + } + seen[item.TrxLayananID] = struct{}{} + if strings.EqualFold(strings.TrimSpace(item.FlagHide.String), "Y") && item.DisplayResult.Valid && item.DisplayResult.Float64 > 0 { + continue + } + item.ServiceCode = labNullStringPtr(serviceCode) + item.Name = labNullStringPtr(name) + item.Result = labNullStringPtr(resultValue) + item.ReferenceValue = labNullStringPtr(referenceValue) + item.Unit = labNullStringPtr(item.UnitName) + if item.Unit == nil { + item.Unit = labNullStringPtr(item.UnitSnapshot) + } + item.CriticalFlag = strings.TrimSpace(criticalFlag.String) + item.ValueType = labNullStringPtr(valueType) + item.Note = labNullStringPtr(note) + result = append(result, item) + } + if err := rows.Err(); err != nil { + _ = rows.Close() + return nil, err + } + if err := rows.Close(); err != nil { + return nil, err + } + for index := range result { + if result[index].ReferenceValue != nil && result[index].Unit != nil { + continue + } + fallbackReference, fallbackUnit, err := r.findLabReferenceFallback(ctx, lab, result[index]) + if err != nil { + return nil, err + } + if result[index].ReferenceValue == nil { + result[index].ReferenceValue = fallbackReference + } + if result[index].Unit == nil { + result[index].Unit = fallbackUnit + } + } + return result, nil +} + +func (r MySQLLayananRepository) findLabReferenceFallback(ctx context.Context, lab labResultRecord, item rawLabResultItem) (*string, *string, error) { + if !lab.BirthDate.Valid || !lab.Tanggal.Valid { + return nil, nil, nil + } + birthDate, err := time.Parse("2006-01-02", lab.BirthDate.String) + if err != nil { + return nil, nil, nil + } + transactionDate, err := time.Parse("2006-01-02 15:04:05", lab.Tanggal.String) + if err != nil || birthDate.After(transactionDate) { + return nil, nil, nil + } + ageCode := labAgeCode(birthDate, transactionDate) + columns, err := r.tableColumns(ctx, "lab_reference") + if err != nil { + return nil, nil, err + } + analyzerColumn := findColumn(columns, "Lab_AnalyzerID", "AnalyzerID") + methodColumn := findColumn(columns, "Lab_MethodID", "MethodID", "MetodeID") + naColumn := findColumn(columns, "NA") + unitColumn := findColumn(columns, "Satuan", "SatuanID") + unitSelect := "NULL" + if unitColumn != "" { + unitSelect = "`" + unitColumn + "`" + } + + var builder strings.Builder + builder.WriteString("SELECT NilaiNormalKet, NilaiNormal1, NilaiNormal2, " + unitSelect + " FROM lab_reference WHERE LayananID = ? AND KelaminID = ? AND TipeNilai = 'Normal' AND UsiaDari <= ? AND UsiaSampai >= ?") + args := []any{item.ServiceID, lab.Gender.String, ageCode, ageCode} + if naColumn != "" { + builder.WriteString(" AND `" + naColumn + "` = 'N'") + } + if analyzerColumn != "" && item.AnalyzerID.Valid && strings.TrimSpace(item.AnalyzerID.String) != "" { + builder.WriteString(" AND `" + analyzerColumn + "` = ?") + args = append(args, item.AnalyzerID.String) + } + if methodColumn != "" && item.MethodID.Valid && strings.TrimSpace(item.MethodID.String) != "" { + builder.WriteString(" AND `" + methodColumn + "` = ?") + args = append(args, item.MethodID.String) + } + builder.WriteString(" ORDER BY UsiaDari DESC LIMIT 1") + + var description, minimum, maximum, unitID sql.NullString + err = r.db.QueryRowContext(ctx, builder.String(), args...).Scan(&description, &minimum, &maximum, &unitID) + if err == sql.ErrNoRows { + return nil, nil, nil + } + if err != nil { + return nil, nil, err + } + referenceValue := formatLabReferenceValue(description, minimum, maximum) + if !unitID.Valid || strings.TrimSpace(unitID.String) == "" { + return referenceValue, nil, nil + } + unit := strings.TrimSpace(unitID.String) + var unitName sql.NullString + err = r.db.QueryRowContext(ctx, "SELECT Nama FROM satuanlab WHERE SatuanID = ? LIMIT 1", unit).Scan(&unitName) + if err != nil && err != sql.ErrNoRows { + return nil, nil, err + } + if unitName.Valid && strings.TrimSpace(unitName.String) != "" { + return referenceValue, trimmedStringPtr(unitName.String), nil + } + return referenceValue, &unit, nil +} + +func (r MySQLLayananRepository) tableColumns(ctx context.Context, tableName string) ([]string, error) { + rows, err := r.db.QueryContext(ctx, ` + SELECT COLUMN_NAME FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = ? + ORDER BY ORDINAL_POSITION`, tableName) + if err != nil { + return nil, err + } + defer rows.Close() + columns := make([]string, 0) + for rows.Next() { + var column string + if err := rows.Scan(&column); err != nil { + return nil, err + } + columns = append(columns, column) + } + return columns, rows.Err() +} + +func findColumn(columns []string, candidates ...string) string { + for _, candidate := range candidates { + for _, column := range columns { + if strings.EqualFold(column, candidate) { + return column + } + } + } + return "" +} + +func labAgeCode(birthDate, transactionDate time.Time) string { + years := transactionDate.Year() - birthDate.Year() + if birthDate.AddDate(years, 0, 0).After(transactionDate) { + years-- + } + afterYears := birthDate.AddDate(years, 0, 0) + months := 0 + for months < 11 && !afterYears.AddDate(0, months+1, 0).After(transactionDate) { + months++ + } + afterMonths := afterYears.AddDate(0, months, 0) + days := int(transactionDate.Sub(afterMonths).Hours() / 24) + return fmt.Sprintf("%03d%02d%02d", years, months, days) +} + +func formatLabReferenceValue(description, minimum, maximum sql.NullString) *string { + if value := labNullStringPtr(description); value != nil { + return value + } + minimumValue := labNullStringPtr(minimum) + maximumValue := labNullStringPtr(maximum) + if minimumValue != nil && maximumValue != nil { + return trimmedStringPtr(*minimumValue + " - " + *maximumValue) + } + if minimumValue != nil { + return minimumValue + } + return maximumValue +} + +func trimmedStringPtr(value string) *string { + value = strings.TrimSpace(value) + if value == "" { + return nil + } + return &value +} + +func labNullStringPtr(value sql.NullString) *string { + if !value.Valid { + return nil + } + return trimmedStringPtr(value.String) +} + +func labResultOutcome(httpStatus int, success bool, status, message string, data *LabResultData) LabResultOutcome { + return LabResultOutcome{HTTPStatus: httpStatus, Success: success, Status: status, Message: message, Data: data} +} diff --git a/internal/repository/lab_result_test.go b/internal/repository/lab_result_test.go new file mode 100644 index 0000000..3e1ad4f --- /dev/null +++ b/internal/repository/lab_result_test.go @@ -0,0 +1,47 @@ +package repository + +import ( + "database/sql" + "testing" + "time" +) + +func TestLabAgeCodeMatchesCarbonDateDiff(t *testing.T) { + birth := time.Date(1993, time.March, 4, 0, 0, 0, 0, time.UTC) + transaction := time.Date(2026, time.August, 19, 0, 0, 0, 0, time.UTC) + if got := labAgeCode(birth, transaction); got != "0330515" { + t.Fatalf("age code=%s, want 0330515", got) + } + + endOfMonthBirth := time.Date(2025, time.January, 31, 0, 0, 0, 0, time.UTC) + marchFirst := time.Date(2025, time.March, 1, 0, 0, 0, 0, time.UTC) + if got := labAgeCode(endOfMonthBirth, marchFirst); got != "0000029" { + t.Fatalf("end-of-month age code=%s, want 0000029", got) + } +} + +func TestFormatLabReferenceValue(t *testing.T) { + value := formatLabReferenceValue( + sql.NullString{}, + sql.NullString{String: "12.5", Valid: true}, + sql.NullString{String: "17.5", Valid: true}, + ) + if value == nil || *value != "12.5 - 17.5" { + t.Fatalf("reference=%v", value) + } + + description := formatLabReferenceValue(sql.NullString{String: " Normal ", Valid: true}, sql.NullString{}, sql.NullString{}) + if description == nil || *description != "Normal" { + t.Fatalf("description=%v", description) + } +} + +func TestFindColumnIsCaseInsensitiveAndHonorsCandidatePriority(t *testing.T) { + columns := []string{"Lab_AnalyzerID", "AnalyzerID", "PROSES"} + if got := findColumn(columns, "AnalyzerID", "Lab_AnalyzerID"); got != "AnalyzerID" { + t.Fatalf("column=%q", got) + } + if got := findColumn(columns, "Proses"); got != "PROSES" { + t.Fatalf("column=%q", got) + } +}