feat: add lab result endpoint
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
58
internal/http/lab_result.go
Normal file
58
internal/http/lab_result.go
Normal file
@@ -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,
|
||||
})
|
||||
}
|
||||
126
internal/http/lab_result_test.go
Normal file
126
internal/http/lab_result_test.go
Normal file
@@ -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())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
435
internal/repository/lab_result.go
Normal file
435
internal/repository/lab_result.go
Normal file
@@ -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}
|
||||
}
|
||||
47
internal/repository/lab_result_test.go
Normal file
47
internal/repository/lab_result_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user