diff --git a/README.md b/README.md index 837d67f..1e30519 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,62 @@ Service Go untuk endpoint: ```text GET /api/cpone/layanan-mapping-harga?periodeTarifID=10&layananID=6413,6414,1309 Authorization: Bearer + +POST /api/cpone/patients/lab-registration +Authorization: Bearer +Content-Type: application/json + +POST /api/cpone/patients/lab-registration/{regId}/services +Authorization: Bearer +Content-Type: application/json ``` -Format response dibuat mengikuti controller PHP `LayananDepartemenController::mappingHarga`. +Format payload dan response dibuat mengikuti controller PHP CPONE terkait. + +Contoh payload registrasi laboratorium: + +```json +{ + "medrec_id": "00001234", + "tanggal": "2026-08-18", + "departemen_id": "LAB", + "dokter_id": "DR01", + "dokter_pengirim_id": "DR02", + "perujuk": null, + "jenis_pasien_id": "0", + "periode_tarif_id": 10, + "kelas_id": "Default", + "shift_harian_id": null, + "diagnosa_kerja": "Pemeriksaan laboratorium", + "catatan": null, + "channel": "CPONE" +} +``` + +`dokter_pengirim_id` atau `perujuk` wajib diisi. Endpoint ini membuat +registrasi laboratorium tanpa layanan, sama seperti +`PatientController::registerLab`; layanan ditambahkan melalui endpoint terpisah. + +Contoh payload penambahan layanan: + +```json +{ + "layanan": [ + { + "layanan_id": "6413", + "jumlah": 1, + "cito": false, + "markup_cito": 0, + "catatan": null + } + ], + "channel": "CPONE" +} +``` + +Harga, diskon publik, share, unit cost, layanan multiple, dan spesimen diambil +langsung dari master HIS. Layanan yang sudah ada akan dilewati dan dilaporkan +melalui `skipped_layanan_ids`. ## Config diff --git a/cmd/server/main.go b/cmd/server/main.go index 6ab0252..318a2ff 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -45,6 +45,8 @@ func main() { mux := http.NewServeMux() mux.Handle("GET /api/cpone/layanan-mapping-harga", apphttp.BearerAuth(cfg.BearerToken, http.HandlerFunc(handler.MappingHarga))) + 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.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 930fd02..60b6ff0 100644 --- a/internal/http/handler.go +++ b/internal/http/handler.go @@ -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")), diff --git a/internal/http/lab_registration.go b/internal/http/lab_registration.go new file mode 100644 index 0000000..5baae91 --- /dev/null +++ b/internal/http/lab_registration.go @@ -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, + }) +} diff --git a/internal/http/lab_registration_test.go b/internal/http/lab_registration_test.go new file mode 100644 index 0000000..ea7b67b --- /dev/null +++ b/internal/http/lab_registration_test.go @@ -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) +} diff --git a/internal/http/lab_services.go b/internal/http/lab_services.go new file mode 100644 index 0000000..4b90199 --- /dev/null +++ b/internal/http/lab_services.go @@ -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 +} diff --git a/internal/http/lab_services_test.go b/internal/http/lab_services_test.go new file mode 100644 index 0000000..9789cc2 --- /dev/null +++ b/internal/http/lab_services_test.go @@ -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) + } + }) + } +} diff --git a/internal/http/response.go b/internal/http/response.go index e070df7..cd41993 100644 --- a/internal/http/response.go +++ b/internal/http/response.go @@ -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) diff --git a/internal/repository/lab_registration.go b/internal/repository/lab_registration.go new file mode 100644 index 0000000..a1a79b9 --- /dev/null +++ b/internal/repository/lab_registration.go @@ -0,0 +1,678 @@ +package repository + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strconv" + "strings" + "time" + + mysql "github.com/go-sql-driver/mysql" +) + +const labDepartmentModule = "LABORATORIUM" + +type LabRegistrationParams struct { + MedrecID string + RegistrationDate time.Time + DepartmentID string + DoctorID string + SenderDoctorID string + Referrer string + TariffPeriodID int64 + ClassID string + DailyShiftID *int64 + WorkingDiagnosis string + Notes string + Channel string +} + +type LabRegistrationOutcome struct { + HTTPStatus int + Success bool + Status string + Message string + Data *LabRegistrationData +} + +type LabRegistrationData struct { + Registration LabRegistration `json:"registration"` + Transaction *LabTransaction `json:"transaction"` + Layanan []LabService `json:"layanan"` + AddedLayananIDs *[]string `json:"added_layanan_ids,omitempty"` + SkippedLayananIDs *[]string `json:"skipped_layanan_ids,omitempty"` +} + +type LabRegistration struct { + RegID string `json:"RegID"` + MEDRECID string `json:"MEDRECID"` + Tanggal string `json:"Tanggal"` + DepartemenID string `json:"DepartemenID"` + DokterID *string `json:"DokterID"` + JenisPasienID int64 `json:"JenisPasienID"` + JenisRegID *int64 `json:"JenisRegID"` + PeriodeTarifID int64 `json:"PeriodeTarifID"` +} + +type LabTransaction struct { + TrxDepartemenID int64 `json:"TrxDepartemenID"` + TrxID string `json:"TrxID"` + GrandTotal float64 `json:"GrandTotal"` +} + +type LabService struct { + TrxLayananID int64 `json:"TrxLayananID"` + HeaderLayananID *string `json:"HeaderLayananID"` + LayananID *string `json:"LayananID"` + Nama *string `json:"Nama"` + Jumlah *string `json:"Jumlah"` + Cito *string `json:"Cito"` + Harga *string `json:"Harga"` + Diskon *string `json:"Diskon"` + TipeDiskon *string `json:"TipeDiskon"` + DitanggungPasien *string `json:"DitanggungPasien"` + DitanggungPenjamin *string `json:"DitanggungPenjamin"` +} + +type ValidationError struct { + Message string +} + +func (e ValidationError) Error() string { return e.Message } + +type dbLabRegistration struct { + RegID string + MEDRECID string + Tanggal time.Time + DepartemenID string + DokterID sql.NullString + JenisPasienID int64 + JenisRegID sql.NullInt64 + PeriodeTarifID int64 +} + +type dbLabTransaction struct { + TrxDepartemenID int64 + TrxID string +} + +type dbLabPatient struct { + MEDRECID string + Nama string + TanggalLahir sql.NullTime + Kelamin sql.NullString +} + +func (r MySQLLayananRepository) RegisterLab(ctx context.Context, params LabRegistrationParams) (LabRegistrationOutcome, error) { + var lastErr error + for attempt := 0; attempt < 3; attempt++ { + outcome, err := r.registerLabAttempt(ctx, params) + if err == nil { + return outcome, nil + } + + lastErr = err + if !isConcurrencyError(err) || ctx.Err() != nil { + return LabRegistrationOutcome{}, err + } + } + + return LabRegistrationOutcome{}, lastErr +} + +func (r MySQLLayananRepository) registerLabAttempt(ctx context.Context, params LabRegistrationParams) (outcome LabRegistrationOutcome, err error) { + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return LabRegistrationOutcome{}, err + } + + defer func() { + if recovered := recover(); recovered != nil { + _ = tx.Rollback() + panic(recovered) + } + if err != nil { + _ = tx.Rollback() + } + }() + + outcome, err = registerLabInTransaction(ctx, tx, params) + if err != nil { + return LabRegistrationOutcome{}, err + } + if err = tx.Commit(); err != nil { + return LabRegistrationOutcome{}, err + } + + return outcome, nil +} + +func registerLabInTransaction(ctx context.Context, tx *sql.Tx, params LabRegistrationParams) (LabRegistrationOutcome, error) { + departmentActive, err := exists(ctx, tx, ` + SELECT EXISTS( + SELECT 1 FROM departemen + WHERE DepartemenID = ? AND NamaModulExternal = ? AND NA = 'N' + )`, params.DepartmentID, labDepartmentModule) + if err != nil { + return LabRegistrationOutcome{}, err + } + if !departmentActive { + return LabRegistrationOutcome{}, ValidationError{Message: "Departemen tujuan bukan departemen laboratorium aktif"} + } + + patient, found, err := findLabPatientForUpdate(ctx, tx, params.MedrecID) + if err != nil { + return LabRegistrationOutcome{}, err + } + if !found { + return labOutcome(404, false, "patient_not_found", "Pasien tidak ditemukan", nil), nil + } + + existing, found, err := findExistingLabRegistrationForUpdate(ctx, tx, patient.MEDRECID, params.DepartmentID, params.RegistrationDate) + if err != nil { + return LabRegistrationOutcome{}, err + } + if found { + trx, trxFound, err := findLabTransaction(ctx, tx, existing.RegID, params.DepartmentID) + if err != nil { + return LabRegistrationOutcome{}, err + } + data, err := buildLabRegistrationData(ctx, tx, existing, optionalLabTransaction(trx, trxFound)) + if err != nil { + return LabRegistrationOutcome{}, err + } + + return labOutcome( + 200, + true, + "already_registered_visit", + "Pasien sudah memiliki registrasi laboratorium aktif pada hari dan tujuan yang sama", + &data, + ), nil + } + + jakarta, err := time.LoadLocation("Asia/Jakarta") + if err != nil { + return LabRegistrationOutcome{}, err + } + now := time.Now().In(jakarta) + nowText := now.Format("2006-01-02 15:04:05") + + senderDoctorID := strings.TrimSpace(params.SenderDoctorID) + if senderDoctorID == "" && strings.TrimSpace(params.Referrer) != "" { + senderDoctorID = "Luar" + } + + regID, err := nextLabRegistrationID(ctx, tx, params.RegistrationDate, params.Channel, nowText) + if err != nil { + return LabRegistrationOutcome{}, err + } + shiftID, err := resolveDailyShiftID(ctx, tx, params.DailyShiftID, params.RegistrationDate) + if err != nil { + return LabRegistrationOutcome{}, err + } + + var previousVisits int64 + if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM regpas WHERE MEDRECID = ? AND NA = 'N'`, patient.MEDRECID).Scan(&previousVisits); err != nil { + return LabRegistrationOutcome{}, err + } + + _, err = tx.ExecContext(ctx, ` + INSERT INTO regpas ( + RegID, MEDRECID, Nama, ALAMATEXTPAS, TELPEXTPAS, TglLahir, JnsKelamin, + KunjunganKe, Tanggal, JenisPasienID, PeriodeTarifID, PerusahaanID, + GroupTarifID, AsuransiID, ProviderID, DepartemenID, Perujuk, JenisRegID, + DokterID, DokterID2, KelasID, MataUang, Rate, Pulang, BolehPulang, Lunas, + BatalDaftar, Catatan, ShiftHarianIDDaftar, LoginBuat, TanggalBuat, NA + ) VALUES (?, ?, ?, '', '', ?, ?, ?, ?, '0', ?, '', '0', '', '', ?, ?, '3', ?, ?, ?, 'IDR', 1, 'N', 'N', 'N', 'N', ?, ?, ?, ?, 'N')`, + regID, + patient.MEDRECID, + patient.Nama, + nullTimeValue(patient.TanggalLahir), + nullStringValue(patient.Kelamin), + previousVisits+1, + nowText, + params.TariffPeriodID, + params.DepartmentID, + strings.TrimSpace(params.Referrer), + params.DoctorID, + senderDoctorID, + params.ClassID, + strings.TrimSpace(params.Notes), + shiftID, + params.Channel, + nowText, + ) + if err != nil { + return LabRegistrationOutcome{}, err + } + + result, err := tx.ExecContext(ctx, ` + INSERT INTO trxdepartemen ( + RegID, TrxID, DepartemenID, DokterID, Dokter2, Tanggal, KelasID, + MataUang, LoginBuat, TanggalBuat, NA + ) VALUES (?, ?, ?, ?, ?, ?, ?, 'IDR', ?, ?, 'N')`, + regID, + regID, + params.DepartmentID, + params.DoctorID, + senderDoctorID, + nowText, + params.ClassID, + params.Channel, + nowText, + ) + if err != nil { + return LabRegistrationOutcome{}, err + } + trxDepartmentID, err := result.LastInsertId() + if err != nil { + return LabRegistrationOutcome{}, err + } + + if err := upsertLabNominals(ctx, tx, regID, trxDepartmentID, params.DepartmentID); err != nil { + return LabRegistrationOutcome{}, err + } + if err := saveLabDiagnosis(ctx, tx, regID, patient.MEDRECID, strings.TrimSpace(params.WorkingDiagnosis), params.Channel, nowText); err != nil { + return LabRegistrationOutcome{}, err + } + + registration, err := findLabRegistrationByID(ctx, tx, regID) + if err != nil { + return LabRegistrationOutcome{}, err + } + transaction, err := findLabTransactionByID(ctx, tx, trxDepartmentID) + if err != nil { + return LabRegistrationOutcome{}, err + } + data, err := buildLabRegistrationData(ctx, tx, registration, &transaction) + if err != nil { + return LabRegistrationOutcome{}, err + } + + return labOutcome(201, true, "registered", "Registrasi laboratorium berhasil disimpan", &data), nil +} + +func findLabRegistrationByID(ctx context.Context, tx *sql.Tx, regID string) (dbLabRegistration, error) { + var registration dbLabRegistration + err := tx.QueryRowContext(ctx, ` + SELECT RegID, MEDRECID, Tanggal, DepartemenID, DokterID, + JenisPasienID, JenisRegID, PeriodeTarifID + FROM regpas WHERE RegID = ? LIMIT 1`, regID).Scan( + ®istration.RegID, + ®istration.MEDRECID, + ®istration.Tanggal, + ®istration.DepartemenID, + ®istration.DokterID, + ®istration.JenisPasienID, + ®istration.JenisRegID, + ®istration.PeriodeTarifID, + ) + return registration, err +} + +func findLabTransactionByID(ctx context.Context, tx *sql.Tx, trxDepartmentID int64) (dbLabTransaction, error) { + var transaction dbLabTransaction + err := tx.QueryRowContext(ctx, ` + SELECT TrxDepartemenID, TrxID + FROM trxdepartemen WHERE TrxDepartemenID = ? LIMIT 1`, trxDepartmentID).Scan( + &transaction.TrxDepartemenID, + &transaction.TrxID, + ) + return transaction, err +} + +func findLabPatientForUpdate(ctx context.Context, tx *sql.Tx, medrecID string) (dbLabPatient, bool, error) { + var patient dbLabPatient + err := tx.QueryRowContext(ctx, ` + SELECT MEDRECID, Nama, TanggalLahir, Kelamin + FROM medrec + WHERE MEDRECID = ? AND NA = 'N' + LIMIT 1 FOR UPDATE`, medrecID).Scan( + &patient.MEDRECID, + &patient.Nama, + &patient.TanggalLahir, + &patient.Kelamin, + ) + if errors.Is(err, sql.ErrNoRows) { + return dbLabPatient{}, false, nil + } + if err != nil { + return dbLabPatient{}, false, err + } + return patient, true, nil +} + +func findExistingLabRegistrationForUpdate( + ctx context.Context, + tx *sql.Tx, + medrecID string, + departmentID string, + registrationDate time.Time, +) (dbLabRegistration, bool, error) { + var registration dbLabRegistration + err := tx.QueryRowContext(ctx, ` + SELECT RegID, MEDRECID, Tanggal, DepartemenID, DokterID, + JenisPasienID, JenisRegID, PeriodeTarifID + FROM regpas + WHERE MEDRECID = ? + AND DepartemenID = ? + AND DATE(Tanggal) = ? + AND NA = 'N' + AND (BatalDaftar IS NULL OR BatalDaftar = 'N' OR BatalDaftar = '') + LIMIT 1 FOR UPDATE`, medrecID, departmentID, registrationDate.Format("2006-01-02")).Scan( + ®istration.RegID, + ®istration.MEDRECID, + ®istration.Tanggal, + ®istration.DepartemenID, + ®istration.DokterID, + ®istration.JenisPasienID, + ®istration.JenisRegID, + ®istration.PeriodeTarifID, + ) + if errors.Is(err, sql.ErrNoRows) { + return dbLabRegistration{}, false, nil + } + if err != nil { + return dbLabRegistration{}, false, err + } + return registration, true, nil +} + +func findLabTransaction(ctx context.Context, tx *sql.Tx, regID string, departmentID string) (dbLabTransaction, bool, error) { + var transaction dbLabTransaction + err := tx.QueryRowContext(ctx, ` + SELECT TrxDepartemenID, TrxID + FROM trxdepartemen + WHERE RegID = ? AND DepartemenID = ? AND NA = 'N' + LIMIT 1`, regID, departmentID).Scan(&transaction.TrxDepartemenID, &transaction.TrxID) + if errors.Is(err, sql.ErrNoRows) { + return dbLabTransaction{}, false, nil + } + if err != nil { + return dbLabTransaction{}, false, err + } + return transaction, true, nil +} + +func nextLabRegistrationID(ctx context.Context, tx *sql.Tx, date time.Time, channel string, now string) (string, error) { + prefix := "CPLAB" + date.Format("060102") + lastCounter, err := lastLabCounter(ctx, tx, ` + SELECT TrxID FROM trxidlaboratorium + WHERE TrxID LIKE ? + ORDER BY TrxID DESC LIMIT 1 FOR UPDATE`, prefix+"%") + if err != nil { + return "", err + } + if lastCounter == "" { + lastCounter, err = lastLabCounter(ctx, tx, ` + SELECT TrxID FROM trxdepartemen + WHERE TrxID LIKE ? + ORDER BY TrxID DESC LIMIT 1 FOR UPDATE`, prefix+"%") + if err != nil { + return "", err + } + } + + sequence := 1 + if lastCounter != "" { + parsed, _ := strconv.Atoi(strings.TrimPrefix(lastCounter, prefix)) + sequence = parsed + 1 + } + regID := fmt.Sprintf("%s%04d", prefix, sequence) + + _, err = tx.ExecContext(ctx, ` + INSERT INTO trxidlaboratorium (TrxID, UserID, Tanggal) + VALUES (?, ?, ?)`, regID, channel, now) + if err != nil { + return "", err + } + return regID, nil +} + +func lastLabCounter(ctx context.Context, tx *sql.Tx, query string, args ...any) (string, error) { + var counter string + err := tx.QueryRowContext(ctx, query, args...).Scan(&counter) + if errors.Is(err, sql.ErrNoRows) { + return "", nil + } + return counter, err +} + +func resolveDailyShiftID(ctx context.Context, tx *sql.Tx, requested *int64, date time.Time) (any, error) { + if requested != nil { + active, err := exists(ctx, tx, ` + SELECT EXISTS( + SELECT 1 FROM shiftharian + WHERE ShiftHarianID = ? AND NA = 'N' + )`, *requested) + if err != nil { + return nil, err + } + if !active { + return nil, ValidationError{Message: "Shift harian tidak ditemukan atau sudah tidak aktif"} + } + return *requested, nil + } + + var shiftID sql.NullInt64 + err := tx.QueryRowContext(ctx, ` + SELECT ShiftHarianID FROM shiftharian + WHERE DATE(Tanggal) = ? AND NA = 'N' + ORDER BY ShiftHarianID DESC LIMIT 1`, date.Format("2006-01-02")).Scan(&shiftID) + if errors.Is(err, sql.ErrNoRows) { + return "", nil + } + if err != nil { + return nil, err + } + if !shiftID.Valid { + return "", nil + } + return shiftID.Int64, nil +} + +func upsertLabNominals(ctx context.Context, tx *sql.Tx, regID string, trxDepartmentID int64, departmentID string) error { + regpasNominalExists, err := exists(ctx, tx, `SELECT EXISTS(SELECT 1 FROM regpas_nominal WHERE RegID = ?)`, regID) + if err != nil { + return err + } + if regpasNominalExists { + _, err = tx.ExecContext(ctx, ` + UPDATE regpas_nominal SET + SetAdministrasi = 'Otomatis', BiayaAdministrasi = 0, + AdmDitanggungPasien = 0, AdmDitanggungPenjamin = 0, + AdmDitanggungPasienDefault = 0, AdmDitanggungPenjaminDefault = 0 + WHERE RegID = ?`, regID) + } else { + _, err = tx.ExecContext(ctx, ` + INSERT INTO regpas_nominal ( + RegID, SetAdministrasi, BiayaAdministrasi, AdmDitanggungPasien, + AdmDitanggungPenjamin, AdmDitanggungPasienDefault, AdmDitanggungPenjaminDefault + ) VALUES (?, 'Otomatis', 0, 0, 0, 0, 0)`, regID) + } + if err != nil { + return err + } + + trxNominalExists, err := exists(ctx, tx, ` + SELECT EXISTS(SELECT 1 FROM trxdepartemen_nominal WHERE TrxDepartemenID = ?)`, trxDepartmentID) + if err != nil { + return err + } + if trxNominalExists { + _, err = tx.ExecContext(ctx, ` + UPDATE trxdepartemen_nominal SET + RegID = ?, DepartemenID = ?, JumlahNominalTrxLayanan = 0, + JumlahNominalTrxItem = 0, JumlahNominalTrxTT = 0, + JumlahNominalTotalTrx = 0 + WHERE TrxDepartemenID = ?`, regID, departmentID, trxDepartmentID) + } else { + _, err = tx.ExecContext(ctx, ` + INSERT INTO trxdepartemen_nominal ( + TrxDepartemenID, RegID, DepartemenID, JumlahNominalTrxLayanan, + JumlahNominalTrxItem, JumlahNominalTrxTT, JumlahNominalTotalTrx + ) VALUES (?, ?, ?, 0, 0, 0, 0)`, trxDepartmentID, regID, departmentID) + } + return err +} + +func saveLabDiagnosis(ctx context.Context, tx *sql.Tx, regID string, medrecID string, diagnosis string, channel string, now string) error { + _, err := tx.ExecContext(ctx, ` + INSERT INTO diagnosa_log ( + RegID, MEDRECID, DiagnosaKerja0, DiagnosaKerja, TanggalBuat, LoginBuat, NA + ) VALUES (?, ?, '', ?, ?, ?, 'N')`, regID, medrecID, diagnosis, now, channel) + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, ` + INSERT INTO regpas_diagnosa (RegID, MEDRECID, DiagnosaKerja, DiagnosaKerjaOleh) + VALUES (?, ?, ?, ?)`, regID, medrecID, diagnosis, channel) + return err +} + +func buildLabRegistrationData(ctx context.Context, tx *sql.Tx, registration dbLabRegistration, transaction *dbLabTransaction) (LabRegistrationData, error) { + data := LabRegistrationData{ + Registration: LabRegistration{ + RegID: registration.RegID, + MEDRECID: registration.MEDRECID, + Tanggal: registration.Tanggal.Format("2006-01-02 15:04:05"), + DepartemenID: registration.DepartemenID, + DokterID: nullStringPtr(registration.DokterID), + JenisPasienID: registration.JenisPasienID, + JenisRegID: nullInt64Ptr(registration.JenisRegID), + PeriodeTarifID: registration.PeriodeTarifID, + }, + Layanan: make([]LabService, 0), + } + if transaction == nil { + return data, nil + } + + services, grandTotal, err := findLabServices(ctx, tx, transaction.TrxDepartemenID) + if err != nil { + return LabRegistrationData{}, err + } + data.Transaction = &LabTransaction{ + TrxDepartemenID: transaction.TrxDepartemenID, + TrxID: transaction.TrxID, + GrandTotal: grandTotal, + } + data.Layanan = services + return data, nil +} + +func findLabServices(ctx context.Context, tx *sql.Tx, trxDepartmentID int64) ([]LabService, float64, error) { + rows, err := tx.QueryContext(ctx, ` + SELECT tl.TrxLayananID, tl.HeaderLayananID, tl.LayananID, ml.Nama, + tl.Jumlah, tl.Cito, tl.Harga, tl.Diskon, tl.TipeDiskon, + tl.DitanggungPasien, tl.DitanggungPenjamin + FROM trxlayanan AS tl + LEFT JOIN masterlayanan AS ml ON ml.LayananID = tl.LayananID + WHERE tl.TrxDepartemenID = ? AND tl.NA = 'N' + ORDER BY tl.TrxLayananID`, trxDepartmentID) + if err != nil { + return nil, 0, err + } + defer rows.Close() + + services := make([]LabService, 0) + grandTotal := 0.0 + for rows.Next() { + var service LabService + var headerID sql.NullString + var layananID, name, quantity, cito, price, discount, discountType sql.NullString + var patientAmount, guarantorAmount sql.NullString + if err := rows.Scan( + &service.TrxLayananID, + &headerID, + &layananID, + &name, + &quantity, + &cito, + &price, + &discount, + &discountType, + &patientAmount, + &guarantorAmount, + ); err != nil { + return nil, 0, err + } + + service.HeaderLayananID = nullStringPtr(headerID) + service.LayananID = nullStringPtr(layananID) + service.Nama = nullStringPtr(name) + service.Jumlah = nullStringPtr(quantity) + service.Cito = nullStringPtr(cito) + service.Harga = nullStringPtr(price) + service.Diskon = nullStringPtr(discount) + service.TipeDiskon = nullStringPtr(discountType) + service.DitanggungPasien = nullStringPtr(patientAmount) + service.DitanggungPenjamin = nullStringPtr(guarantorAmount) + grandTotal += parseNullableDecimal(patientAmount) + parseNullableDecimal(guarantorAmount) + services = append(services, service) + } + if err := rows.Err(); err != nil { + return nil, 0, err + } + return services, grandTotal, nil +} + +func exists(ctx context.Context, tx *sql.Tx, query string, args ...any) (bool, error) { + var value bool + err := tx.QueryRowContext(ctx, query, args...).Scan(&value) + return value, err +} + +func optionalLabTransaction(value dbLabTransaction, valid bool) *dbLabTransaction { + if !valid { + return nil + } + return &value +} + +func labOutcome(httpStatus int, success bool, status string, message string, data *LabRegistrationData) LabRegistrationOutcome { + return LabRegistrationOutcome{ + HTTPStatus: httpStatus, + Success: success, + Status: status, + Message: message, + Data: data, + } +} + +func nullTimeValue(value sql.NullTime) any { + if !value.Valid { + return nil + } + return value.Time +} + +func nullStringValue(value sql.NullString) any { + if !value.Valid { + return nil + } + return value.String +} + +func parseNullableDecimal(value sql.NullString) float64 { + if !value.Valid { + return 0 + } + parsed, _ := strconv.ParseFloat(value.String, 64) + return parsed +} + +func isConcurrencyError(err error) bool { + var mysqlErr *mysql.MySQLError + if errors.As(err, &mysqlErr) && (mysqlErr.Number == 1205 || mysqlErr.Number == 1213) { + return true + } + + message := strings.ToLower(err.Error()) + return strings.Contains(message, "deadlock") || + strings.Contains(message, "lock wait timeout") || + strings.Contains(message, "serialization failure") +} diff --git a/internal/repository/lab_services.go b/internal/repository/lab_services.go new file mode 100644 index 0000000..d65d5a8 --- /dev/null +++ b/internal/repository/lab_services.go @@ -0,0 +1,881 @@ +package repository + +import ( + "context" + "database/sql" + "errors" + "fmt" + "math" + "strconv" + "strings" + "time" +) + +type AddLabServicesParams struct { + Layanan []RequestedLabService + Channel string +} + +type RequestedLabService struct { + LayananID string + Jumlah int + Cito bool + MarkupCito float64 + Catatan string +} + +type resolvedLabService struct { + LayananID string + LayananDepartemenID int64 + Satuan sql.NullString + UnitCost float64 + FlagTipe sql.NullString + Jumlah int + Cito string + MarkupCito float64 + Harga float64 + HargaDefault float64 + Total float64 + ShareTipe string + ShareRS float64 + ShareDokter float64 + ShareExternal float64 + ShareLain float64 + Catatan string +} + +type labRegistrationServiceState struct { + Registration dbLabRegistration + KelasID sql.NullString + BatalDaftar string + Pulang string +} + +type labTransactionServiceState struct { + Transaction dbLabTransaction + DepartemenID sql.NullString + DokterID sql.NullString + KelasID sql.NullString +} + +type multipleLabDetail struct { + LayananID string + HeaderDetail sql.NullInt64 + Urutan sql.NullInt64 + LevelLayanan sql.NullInt64 + Satuan sql.NullString + UnitCost sql.NullString + LayananDepartemenID sql.NullInt64 +} + +func (r MySQLLayananRepository) AddLabServices(ctx context.Context, regID string, params AddLabServicesParams) (LabRegistrationOutcome, error) { + var lastErr error + for attempt := 0; attempt < 3; attempt++ { + outcome, err := r.addLabServicesAttempt(ctx, regID, params) + if err == nil { + return outcome, nil + } + lastErr = err + if !isConcurrencyError(err) || ctx.Err() != nil { + return LabRegistrationOutcome{}, err + } + } + return LabRegistrationOutcome{}, lastErr +} + +func (r MySQLLayananRepository) addLabServicesAttempt(ctx context.Context, regID string, params AddLabServicesParams) (outcome LabRegistrationOutcome, err error) { + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return LabRegistrationOutcome{}, err + } + defer func() { + if recovered := recover(); recovered != nil { + _ = tx.Rollback() + panic(recovered) + } + if err != nil { + _ = tx.Rollback() + } + }() + + outcome, err = addLabServicesInTransaction(ctx, tx, regID, params) + if err != nil { + return LabRegistrationOutcome{}, err + } + if err = tx.Commit(); err != nil { + return LabRegistrationOutcome{}, err + } + return outcome, nil +} + +func addLabServicesInTransaction(ctx context.Context, tx *sql.Tx, regID string, params AddLabServicesParams) (LabRegistrationOutcome, error) { + registration, found, err := findLabRegistrationStateForUpdate(ctx, tx, regID) + if err != nil { + return LabRegistrationOutcome{}, err + } + if !found { + return labOutcome(404, false, "registration_not_found", "Registrasi laboratorium tidak ditemukan", nil), nil + } + if registration.Registration.JenisPasienID != 0 { + return LabRegistrationOutcome{}, ValidationError{Message: "Penambahan layanan laboratorium API saat ini hanya mendukung pasien umum"} + } + if registration.BatalDaftar == "Y" { + return labOutcome(409, false, "registration_cancelled", "Registrasi laboratorium sudah dibatalkan", nil), nil + } + if registration.Pulang == "Y" { + return labOutcome(409, false, "registration_closed", "Registrasi laboratorium sudah ditutup", nil), nil + } + + transaction, found, err := findLabTransactionStateForUpdate( + ctx, + tx, + registration.Registration.RegID, + registration.Registration.DepartemenID, + ) + if err != nil { + return LabRegistrationOutcome{}, err + } + if !found { + return labOutcome(404, false, "transaction_not_found", "Transaksi departemen laboratorium tidak ditemukan", nil), nil + } + if registration.Registration.PeriodeTarifID < 1 { + return LabRegistrationOutcome{}, ValidationError{Message: "Periode tarif registrasi tidak valid"} + } + + classID := "Default" + if transaction.KelasID.Valid { + classID = transaction.KelasID.String + } else if registration.KelasID.Valid { + classID = registration.KelasID.String + } + classID = strings.TrimSpace(classID) + if classID == "" { + classID = "Default" + } + + requestedIDs := make([]string, len(params.Layanan)) + for index, service := range params.Layanan { + requestedIDs[index] = strings.TrimSpace(service.LayananID) + } + existingIDs, err := findExistingLabServiceIDs(ctx, tx, transaction.Transaction.TrxDepartemenID, requestedIDs) + if err != nil { + return LabRegistrationOutcome{}, err + } + existingSet := make(map[string]struct{}, len(existingIDs)) + for _, serviceID := range existingIDs { + existingSet[serviceID] = struct{}{} + } + servicesToAdd := make([]RequestedLabService, 0, len(params.Layanan)) + for _, service := range params.Layanan { + if _, exists := existingSet[strings.TrimSpace(service.LayananID)]; !exists { + servicesToAdd = append(servicesToAdd, service) + } + } + + if len(servicesToAdd) == 0 { + data, err := buildLabRegistrationData(ctx, tx, registration.Registration, &transaction.Transaction) + if err != nil { + return LabRegistrationOutcome{}, err + } + added := make([]string, 0) + data.AddedLayananIDs = &added + data.SkippedLayananIDs = &existingIDs + return labOutcome( + 200, + true, + "services_already_added", + "Seluruh layanan sudah terdaftar pada kunjungan laboratorium ini", + &data, + ), nil + } + + resolved, err := resolveLabServices( + ctx, + tx, + servicesToAdd, + registration.Registration.DepartemenID, + classID, + registration.Registration.PeriodeTarifID, + ) + if err != nil { + return LabRegistrationOutcome{}, err + } + jakarta, err := time.LoadLocation("Asia/Jakarta") + if err != nil { + return LabRegistrationOutcome{}, err + } + now := time.Now().In(jakarta).Format("2006-01-02 15:04:05") + doctorID := registration.Registration.DokterID + if transaction.DokterID.Valid { + doctorID = transaction.DokterID + } + doctorValue := "" + if doctorID.Valid { + doctorValue = doctorID.String + } + + for _, service := range resolved { + if err := insertResolvedLabService( + ctx, + tx, + service, + registration.Registration.RegID, + transaction.Transaction.TrxDepartemenID, + registration.Registration.MEDRECID, + registration.Registration.DepartemenID, + doctorValue, + classID, + params.Channel, + now, + ); err != nil { + return LabRegistrationOutcome{}, err + } + } + if err := syncLabSpecimens(ctx, tx, transaction.Transaction.TrxDepartemenID, transaction.Transaction.TrxID, params.Channel, now); err != nil { + return LabRegistrationOutcome{}, err + } + if err := updateLabTransactionTotal( + ctx, + tx, + transaction.Transaction.TrxDepartemenID, + registration.Registration.RegID, + registration.Registration.DepartemenID, + ); err != nil { + return LabRegistrationOutcome{}, err + } + + data, err := buildLabRegistrationData(ctx, tx, registration.Registration, &transaction.Transaction) + if err != nil { + return LabRegistrationOutcome{}, err + } + addedIDs := make([]string, len(resolved)) + for index, service := range resolved { + addedIDs[index] = service.LayananID + } + data.AddedLayananIDs = &addedIDs + data.SkippedLayananIDs = &existingIDs + return labOutcome(201, true, "services_added", "Layanan laboratorium berhasil ditambahkan", &data), nil +} + +func findLabRegistrationStateForUpdate(ctx context.Context, tx *sql.Tx, regID string) (labRegistrationServiceState, bool, error) { + var state labRegistrationServiceState + err := tx.QueryRowContext(ctx, ` + SELECT RegID, MEDRECID, Tanggal, DepartemenID, DokterID, + JenisPasienID, JenisRegID, PeriodeTarifID, KelasID, BatalDaftar, Pulang + FROM regpas WHERE RegID = ? AND NA = 'N' LIMIT 1 FOR UPDATE`, regID).Scan( + &state.Registration.RegID, + &state.Registration.MEDRECID, + &state.Registration.Tanggal, + &state.Registration.DepartemenID, + &state.Registration.DokterID, + &state.Registration.JenisPasienID, + &state.Registration.JenisRegID, + &state.Registration.PeriodeTarifID, + &state.KelasID, + &state.BatalDaftar, + &state.Pulang, + ) + if errors.Is(err, sql.ErrNoRows) { + return labRegistrationServiceState{}, false, nil + } + if err != nil { + return labRegistrationServiceState{}, false, err + } + return state, true, nil +} + +func findLabTransactionStateForUpdate(ctx context.Context, tx *sql.Tx, regID string, departmentID string) (labTransactionServiceState, bool, error) { + var state labTransactionServiceState + err := tx.QueryRowContext(ctx, ` + SELECT TrxDepartemenID, TrxID, DepartemenID, DokterID, KelasID + FROM trxdepartemen + WHERE RegID = ? AND DepartemenID = ? AND NA = 'N' + LIMIT 1 FOR UPDATE`, regID, departmentID).Scan( + &state.Transaction.TrxDepartemenID, + &state.Transaction.TrxID, + &state.DepartemenID, + &state.DokterID, + &state.KelasID, + ) + if errors.Is(err, sql.ErrNoRows) { + return labTransactionServiceState{}, false, nil + } + if err != nil { + return labTransactionServiceState{}, false, err + } + return state, true, nil +} + +func findExistingLabServiceIDs(ctx context.Context, tx *sql.Tx, trxDepartmentID int64, serviceIDs []string) ([]string, error) { + placeholders := make([]string, len(serviceIDs)) + args := make([]any, 0, len(serviceIDs)+1) + args = append(args, trxDepartmentID) + for index, serviceID := range serviceIDs { + placeholders[index] = "?" + args = append(args, serviceID) + } + rows, err := tx.QueryContext(ctx, ` + SELECT LayananID FROM trxlayanan + WHERE TrxDepartemenID = ? AND NA = 'N' + AND LayananID IN (`+strings.Join(placeholders, ",")+`)`, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + result := make([]string, 0) + seen := make(map[string]struct{}) + for rows.Next() { + var serviceID sql.NullString + if err := rows.Scan(&serviceID); err != nil { + return nil, err + } + if !serviceID.Valid { + continue + } + if _, exists := seen[serviceID.String]; exists { + continue + } + seen[serviceID.String] = struct{}{} + result = append(result, serviceID.String) + } + return result, rows.Err() +} + +func resolveLabServices( + ctx context.Context, + tx *sql.Tx, + requested []RequestedLabService, + departmentID string, + classID string, + tariffPeriodID int64, +) ([]resolvedLabService, error) { + departmentActive, err := exists(ctx, tx, ` + SELECT EXISTS(SELECT 1 FROM departemen + WHERE DepartemenID = ? AND NamaModulExternal = 'LABORATORIUM' AND NA = 'N')`, departmentID) + if err != nil { + return nil, err + } + if !departmentActive { + return nil, ValidationError{Message: "Departemen tujuan bukan departemen laboratorium aktif"} + } + + tariffTable := "harga_layanan_" + strconv.FormatInt(tariffPeriodID, 10) + tariffTableExists, err := tableExistsInTransaction(ctx, tx, tariffTable) + if err != nil { + return nil, err + } + if !tariffTableExists { + return nil, ValidationError{Message: "Tabel periode tarif tidak ditemukan: " + tariffTable} + } + publicDiscountTableExists, err := tableExistsInTransaction(ctx, tx, "harga_layanan_diskon") + if err != nil { + return nil, err + } + + resolved := make([]resolvedLabService, 0, len(requested)) + for _, requestedService := range requested { + serviceID := strings.TrimSpace(requestedService.LayananID) + service, found, err := findLabServiceMaster(ctx, tx, serviceID, departmentID) + if err != nil { + return nil, err + } + if !found { + return nil, ValidationError{Message: fmt.Sprintf( + "Layanan %s tidak aktif atau tidak terdaftar pada departemen %s", + serviceID, + departmentID, + )} + } + + tariff, found, err := findLabTariff(ctx, tx, tariffTable, service.LayananDepartemenID, classID) + if err != nil { + return nil, err + } + if !found && classID != "Default" { + tariff, found, err = findLabTariff(ctx, tx, tariffTable, service.LayananDepartemenID, "Default") + if err != nil { + return nil, err + } + } + if !found { + return nil, ValidationError{Message: fmt.Sprintf( + "Tarif layanan %s untuk periode %d dan kelas %s tidak ditemukan", + serviceID, + tariffPeriodID, + classID, + )} + } + + price := nullDecimal(tariff.Harga) + shareType := "Percentage" + if tariff.ShareTipe.Valid { + shareType = tariff.ShareTipe.String + } + shares := [4]float64{ + nullDecimal(tariff.ShareRS), + nullDecimal(tariff.ShareDokter), + nullDecimal(tariff.ShareExternal), + nullDecimal(tariff.ShareLain), + } + + if publicDiscountTableExists { + discount, found, err := findPublicLabDiscount(ctx, tx, tariffPeriodID, classID, serviceID) + if err != nil { + return nil, err + } + if found { + if strings.EqualFold(shareType, "Absolute") && price > 0 { + shares = absoluteSharesToPercentages(shares, price) + shareType = "Percentage" + } + price -= price * discount / 100 + } + } + + defaultPrice := math.Max(0, price) + markup := 0.0 + if requestedService.Cito { + markup = requestedService.MarkupCito + } + finalPrice := defaultPrice + defaultPrice*markup/100 + cito := "N" + if requestedService.Cito { + cito = "Y" + } + resolved = append(resolved, resolvedLabService{ + LayananID: service.LayananID, + LayananDepartemenID: service.LayananDepartemenID, + Satuan: service.Satuan, + UnitCost: nullDecimal(service.UnitCost), + FlagTipe: service.FlagTipe, + Jumlah: requestedService.Jumlah, + Cito: cito, + MarkupCito: markup, + Harga: finalPrice, + HargaDefault: defaultPrice, + Total: math.Max(0, finalPrice) * float64(requestedService.Jumlah), + ShareTipe: shareType, + ShareRS: shares[0], + ShareDokter: shares[1], + ShareExternal: shares[2], + ShareLain: shares[3], + Catatan: strings.TrimSpace(requestedService.Catatan), + }) + } + return resolved, nil +} + +type labServiceMaster struct { + LayananDepartemenID int64 + LayananID string + UnitCost sql.NullString + Satuan sql.NullString + FlagTipe sql.NullString +} + +func findLabServiceMaster(ctx context.Context, tx *sql.Tx, serviceID string, departmentID string) (labServiceMaster, bool, error) { + var service labServiceMaster + err := tx.QueryRowContext(ctx, ` + SELECT ld.LayananDepartemenID, ld.LayananID, ml.UnitCost, ml.Satuan, lml.FlagTipe + FROM layanandepartemen AS ld + INNER JOIN masterlayanan AS ml ON ml.LayananID = ld.LayananID + LEFT JOIN lab_masterlayanan AS lml ON lml.LayananID = ml.LayananID + WHERE ld.LayananID = ? AND ld.DepartemenID = ? + AND ld.NA = 'N' AND ml.NA = 'N' + LIMIT 1`, serviceID, departmentID).Scan( + &service.LayananDepartemenID, + &service.LayananID, + &service.UnitCost, + &service.Satuan, + &service.FlagTipe, + ) + if errors.Is(err, sql.ErrNoRows) { + return labServiceMaster{}, false, nil + } + if err != nil { + return labServiceMaster{}, false, err + } + return service, true, nil +} + +type labTariff struct { + Harga sql.NullString + ShareTipe sql.NullString + ShareRS sql.NullString + ShareDokter sql.NullString + ShareExternal sql.NullString + ShareLain sql.NullString +} + +func findLabTariff(ctx context.Context, tx *sql.Tx, table string, serviceDepartmentID int64, classID string) (labTariff, bool, error) { + var tariff labTariff + err := tx.QueryRowContext(ctx, ` + SELECT Harga, ShareTipe, ShareRS, ShareDokter, ShareExternal, ShareLain + FROM `+table+` + WHERE LayananDepartemenID = ? AND KelasID = ? AND NA = 'N' + LIMIT 1`, serviceDepartmentID, classID).Scan( + &tariff.Harga, + &tariff.ShareTipe, + &tariff.ShareRS, + &tariff.ShareDokter, + &tariff.ShareExternal, + &tariff.ShareLain, + ) + if errors.Is(err, sql.ErrNoRows) { + return labTariff{}, false, nil + } + if err != nil { + return labTariff{}, false, err + } + return tariff, true, nil +} + +func findPublicLabDiscount(ctx context.Context, tx *sql.Tx, tariffPeriodID int64, classID string, serviceID string) (float64, bool, error) { + var discount sql.NullString + err := tx.QueryRowContext(ctx, ` + SELECT DiskonPercentage FROM harga_layanan_diskon + WHERE PeriodeTarifID = ? AND KelasID = ? AND LayananID = ? AND NA = 'N' + LIMIT 1`, tariffPeriodID, classID, serviceID).Scan(&discount) + if errors.Is(err, sql.ErrNoRows) { + return 0, false, nil + } + if err != nil { + return 0, false, err + } + return nullDecimal(discount), true, nil +} + +func insertResolvedLabService( + ctx context.Context, + tx *sql.Tx, + service resolvedLabService, + regID string, + trxDepartmentID int64, + medrecID string, + departmentID string, + doctorID string, + classID string, + channel string, + now string, +) error { + headerDetail := 1 + if service.FlagTipe.Valid && service.FlagTipe.String == "M" { + headerDetail = 0 + } + result, err := tx.ExecContext(ctx, ` + INSERT INTO trxlayanan ( + TrxDepartemenID, RegID, MEDRECID, DepartemenID, DokterID, HeaderDetail, + LayananID, LayananDepartemenID, JenisRegID, KelasID, Catatan, Cito, + MarkUpCito, Jumlah, Satuan, MataUang, Rate, UnitCost, TipeDiskon, Diskon, + HargaDefault, Harga, DitanggungPasien, DitanggungPasienDefault, + DitanggungPenjamin, DitanggungPenjaminDefault, ShareTipe, ShareRS, + ShareDokter, ShareExternal, ShareLain2, Tagihkan, Tampilkan, + LoginBuat, TanggalBuat, NA + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, '3', ?, ?, ?, ?, ?, ?, 'IDR', 1, ?, + 'Percentage', 0, ?, ?, ?, ?, 0, 0, ?, ?, ?, ?, ?, 'Y', 'Y', ?, ?, 'N')`, + trxDepartmentID, + regID, + medrecID, + departmentID, + doctorID, + headerDetail, + service.LayananID, + service.LayananDepartemenID, + classID, + service.Catatan, + service.Cito, + service.MarkupCito, + service.Jumlah, + nullStringValue(service.Satuan), + service.UnitCost, + service.HargaDefault, + service.Harga, + service.Total, + service.Total, + service.ShareTipe, + service.ShareRS, + service.ShareDokter, + service.ShareExternal, + service.ShareLain, + channel, + now, + ) + if err != nil { + return err + } + trxServiceID, err := result.LastInsertId() + if err != nil { + return err + } + if headerDetail != 0 { + return nil + } + if err := insertLabServiceSupport(ctx, tx, trxServiceID, service.LayananID); err != nil { + return err + } + + details, err := findMultipleLabDetails(ctx, tx, service.LayananID, departmentID) + if err != nil { + return err + } + for _, detail := range details { + if err := insertMultipleLabDetail( + ctx, + tx, + detail, + service, + regID, + trxDepartmentID, + medrecID, + departmentID, + doctorID, + classID, + channel, + now, + ); err != nil { + return err + } + } + return nil +} + +func findMultipleLabDetails(ctx context.Context, tx *sql.Tx, headerServiceID string, departmentID string) ([]multipleLabDetail, error) { + rows, err := tx.QueryContext(ctx, ` + SELECT lm.LayananID, lm.HeaderDetail, lm.Urutan, lm.LevelLayanan, + ml.Satuan, ml.UnitCost, ld.LayananDepartemenID + FROM lab_multiple AS lm + INNER JOIN masterlayanan AS ml ON ml.LayananID = lm.LayananID + LEFT JOIN layanandepartemen AS ld + ON ld.LayananID = lm.LayananID + AND ld.DepartemenID = ? AND ld.NA = 'N' + WHERE lm.LayananIDHeader = ? AND lm.NA = 'N' AND ml.NA = 'N' + ORDER BY lm.Urutan`, departmentID, headerServiceID) + if err != nil { + return nil, err + } + defer rows.Close() + + details := make([]multipleLabDetail, 0) + for rows.Next() { + var detail multipleLabDetail + if err := rows.Scan( + &detail.LayananID, + &detail.HeaderDetail, + &detail.Urutan, + &detail.LevelLayanan, + &detail.Satuan, + &detail.UnitCost, + &detail.LayananDepartemenID, + ); err != nil { + return nil, err + } + details = append(details, detail) + } + return details, rows.Err() +} + +func insertMultipleLabDetail( + ctx context.Context, + tx *sql.Tx, + detail multipleLabDetail, + header resolvedLabService, + regID string, + trxDepartmentID int64, + medrecID string, + departmentID string, + doctorID string, + classID string, + channel string, + now string, +) error { + headerDetail := int64(1) + if detail.HeaderDetail.Valid { + headerDetail = detail.HeaderDetail.Int64 + } + serviceDepartmentID := any("-") + if detail.LayananDepartemenID.Valid { + serviceDepartmentID = detail.LayananDepartemenID.Int64 + } + result, err := tx.ExecContext(ctx, ` + INSERT INTO trxlayanan ( + TrxDepartemenID, RegID, MEDRECID, DepartemenID, DokterID, HeaderDetail, + Urutan, LevelLayanan, LayananID, LayananDepartemenID, JenisRegID, KelasID, + Cito, MarkUpCito, Jumlah, Satuan, MataUang, Rate, UnitCost, TipeDiskon, + Diskon, HargaDefault, Harga, DitanggungPasien, DitanggungPasienDefault, + DitanggungPenjamin, DitanggungPenjaminDefault, ShareTipe, ShareRS, + ShareDokter, ShareExternal, ShareLain2, Tagihkan, Tampilkan, + LoginBuat, TanggalBuat, NA + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '3', ?, ?, ?, ?, ?, 'IDR', 1, ?, + 'Percentage', 0, 0, 0, 0, 0, 0, 0, 'Percentage', 0, 0, 0, 0, + 'Y', 'Y', ?, ?, 'N')`, + trxDepartmentID, + regID, + medrecID, + departmentID, + doctorID, + headerDetail, + nullInt64Value(detail.Urutan), + nullInt64Value(detail.LevelLayanan), + detail.LayananID, + serviceDepartmentID, + classID, + header.Cito, + header.MarkupCito, + header.Jumlah, + nullStringValue(detail.Satuan), + nullDecimal(detail.UnitCost), + channel, + now, + ) + if err != nil { + return err + } + trxServiceID, err := result.LastInsertId() + if err != nil { + return err + } + return insertLabServiceSupport(ctx, tx, trxServiceID, header.LayananID) +} + +func insertLabServiceSupport(ctx context.Context, tx *sql.Tx, trxServiceID int64, headerServiceID string) error { + _, err := tx.ExecContext(ctx, ` + INSERT INTO trxlayanan_penunjang (TrxLayananID, LayananIDHeader) + VALUES (?, ?)`, trxServiceID, headerServiceID) + return err +} + +func syncLabSpecimens(ctx context.Context, tx *sql.Tx, trxDepartmentID int64, trxID string, channel string, now string) error { + rows, err := tx.QueryContext(ctx, ` + SELECT ls.Lab_JenisSpesimenID, MAX(tl.Jumlah) AS Qty + FROM trxlayanan AS tl + INNER JOIN lab_spesimentest AS ls ON ls.LayananID = tl.LayananID + INNER JOIN lab_jenisspesimen AS lj ON lj.Lab_JenisSpesimenID = ls.Lab_JenisSpesimenID + WHERE tl.TrxDepartemenID = ? AND tl.NA = 'N' AND ls.NA = 'N' AND lj.NA = 'N' + GROUP BY ls.Lab_JenisSpesimenID`, trxDepartmentID) + if err != nil { + return err + } + type specimen struct { + ID int64 + Qty sql.NullString + } + specimens := make([]specimen, 0) + for rows.Next() { + var value specimen + if err := rows.Scan(&value.ID, &value.Qty); err != nil { + _ = rows.Close() + return err + } + specimens = append(specimens, value) + } + if err := rows.Close(); err != nil { + return err + } + if err := rows.Err(); err != nil { + return err + } + + for _, value := range specimens { + present, err := exists(ctx, tx, ` + SELECT EXISTS(SELECT 1 FROM lab_trxspesimen + WHERE TrxDepartemenID = ? AND Lab_JenisSpesimenID = ? AND NA = 'N')`, trxDepartmentID, value.ID) + if err != nil { + return err + } + if present { + continue + } + quantity := int64(nullDecimal(value.Qty)) + if quantity < 1 { + quantity = 1 + } + _, err = tx.ExecContext(ctx, ` + INSERT INTO lab_trxspesimen ( + TrxDepartemenID, TrxID, Lab_JenisSpesimenID, Qty, LoginBuat, TanggalBuat, NA + ) VALUES (?, ?, ?, ?, ?, ?, 'N')`, trxDepartmentID, trxID, value.ID, quantity, channel, now) + if err != nil { + return err + } + } + return nil +} + +func updateLabTransactionTotal(ctx context.Context, tx *sql.Tx, trxDepartmentID int64, regID string, departmentID string) error { + var totalValue sql.NullString + if err := tx.QueryRowContext(ctx, ` + SELECT COALESCE(SUM(DitanggungPasien + DitanggungPenjamin), 0) + FROM trxlayanan + WHERE TrxDepartemenID = ? AND Tagihkan = 'Y' AND NA = 'N'`, trxDepartmentID).Scan(&totalValue); err != nil { + return err + } + totalServices := nullDecimal(totalValue) + + var totalItems, totalBeds sql.NullString + err := tx.QueryRowContext(ctx, ` + SELECT JumlahNominalTrxItem, JumlahNominalTrxTT + FROM trxdepartemen_nominal + WHERE TrxDepartemenID = ? LIMIT 1 FOR UPDATE`, trxDepartmentID).Scan(&totalItems, &totalBeds) + found := err == nil + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return err + } + itemValue := nullDecimal(totalItems) + bedValue := nullDecimal(totalBeds) + total := totalServices + itemValue + bedValue + if found { + _, err = tx.ExecContext(ctx, ` + UPDATE trxdepartemen_nominal SET RegID = ?, DepartemenID = ?, + JumlahNominalTrxLayanan = ?, JumlahNominalTrxItem = ?, + JumlahNominalTrxTT = ?, JumlahNominalTotalTrx = ? + WHERE TrxDepartemenID = ?`, regID, departmentID, totalServices, itemValue, bedValue, total, trxDepartmentID) + } else { + _, err = tx.ExecContext(ctx, ` + INSERT INTO trxdepartemen_nominal ( + TrxDepartemenID, RegID, DepartemenID, JumlahNominalTrxLayanan, + JumlahNominalTrxItem, JumlahNominalTrxTT, JumlahNominalTotalTrx + ) VALUES (?, ?, ?, ?, ?, ?, ?)`, trxDepartmentID, regID, departmentID, totalServices, itemValue, bedValue, total) + } + return err +} + +func tableExistsInTransaction(ctx context.Context, tx *sql.Tx, table string) (bool, error) { + return exists(ctx, tx, ` + SELECT EXISTS(SELECT 1 FROM information_schema.tables + WHERE table_schema = DATABASE() AND table_name = ?)`, table) +} + +func absoluteSharesToPercentages(shares [4]float64, price float64) [4]float64 { + if price <= 0 { + return shares + } + largestIndex := 0 + sum := 0.0 + for index, share := range shares { + shares[index] = math.Round((share/price)*100*100) / 100 + sum += shares[index] + if shares[index] > shares[largestIndex] { + largestIndex = index + } + } + difference := math.Round((100-sum)*100) / 100 + if difference != 0 { + shares[largestIndex] = math.Round((shares[largestIndex]+difference)*100) / 100 + } + return shares +} + +func nullDecimal(value sql.NullString) float64 { + if !value.Valid { + return 0 + } + parsed, _ := strconv.ParseFloat(value.String, 64) + return parsed +} + +func nullInt64Value(value sql.NullInt64) any { + if !value.Valid { + return nil + } + return value.Int64 +} diff --git a/internal/repository/lab_services_test.go b/internal/repository/lab_services_test.go new file mode 100644 index 0000000..1fa0daa --- /dev/null +++ b/internal/repository/lab_services_test.go @@ -0,0 +1,16 @@ +package repository + +import "testing" + +func TestAbsoluteSharesToPercentagesMatchesLaravel(t *testing.T) { + result := absoluteSharesToPercentages([4]float64{50000, 30000, 10000, 10000}, 100000) + expected := [4]float64{50, 30, 10, 10} + if result != expected { + t.Fatalf("shares = %v, want %v", result, expected) + } + + withRounding := absoluteSharesToPercentages([4]float64{1, 1, 1, 0}, 3) + if withRounding != [4]float64{33.34, 33.33, 33.33, 0} { + t.Fatalf("rounded shares = %v", withRounding) + } +}