From 13bbdc1328a156b6b17d88a95239140082b64767 Mon Sep 17 00:00:00 2001 From: Tubagus Fajri Date: Tue, 18 Aug 2026 15:04:14 +0700 Subject: [PATCH] feat(cpone): migrate medrec endpoint --- README.md | 31 ++++ cmd/server/main.go | 1 + internal/http/handler.go | 8 +- internal/http/medrec.go | 230 +++++++++++++++++++++++++ internal/http/medrec_test.go | 191 +++++++++++++++++++++ internal/repository/medrec.go | 310 ++++++++++++++++++++++++++++++++++ 6 files changed, 770 insertions(+), 1 deletion(-) create mode 100644 internal/http/medrec.go create mode 100644 internal/http/medrec_test.go create mode 100644 internal/repository/medrec.go diff --git a/README.md b/README.md index 1e30519..bb27cc2 100644 --- a/README.md +++ b/README.md @@ -13,10 +13,41 @@ Content-Type: application/json POST /api/cpone/patients/lab-registration/{regId}/services Authorization: Bearer Content-Type: application/json + +POST /api/cpone/patients/medrec +Authorization: Bearer +Content-Type: application/json ``` Format payload dan response dibuat mengikuti controller PHP CPONE terkait. +Contoh payload pembuatan pasien HIS: + +```json +{ + "rs": "PRIMAYA", + "nama": "BUDI SANTOS", + "jenis_kelamin": "L", + "tgl_lahir": "1990-01-15", + "jenis_nomer_sosial": "KTP", + "nomer_sosial": "3174011501900001", + "telepon": "081234567890", + "ponsel": "081234567890", + "email": "budi@example.com", + "tempat_lahir": "JAKARTA", + "alamat": "JL. CONTOH NO. 1", + "propinsi": "DKI JAKARTA", + "kabupaten": "JAKARTA", + "kecamatan": "KECAMATAN CONTOH", + "kelurahan": "KELURAHAN CONTOH", + "kode_pos": "12345", + "channel": "CPONE" +} +``` + +Endpoint mengembalikan `created`, `already_exists`, atau `possible_duplicate` +sesuai hasil pemeriksaan identitas dan kemiripan data pasien di HIS. + Contoh payload registrasi laboratorium: ```json diff --git a/cmd/server/main.go b/cmd/server/main.go index 318a2ff..f9fb863 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -45,6 +45,7 @@ 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/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.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/http/handler.go b/internal/http/handler.go index 60b6ff0..daa83b5 100644 --- a/internal/http/handler.go +++ b/internal/http/handler.go @@ -21,18 +21,24 @@ type LabRegistrationRepository interface { AddLabServices(ctx context.Context, regID string, params repository.AddLabServicesParams) (repository.LabRegistrationOutcome, error) } +type PatientRepository interface { + StoreMedrec(ctx context.Context, params repository.MedrecParams) (repository.MedrecOutcome, error) +} + type Handler struct { layananRepo LayananRepository labRepo LabRegistrationRepository + patientRepo PatientRepository } type CPONERepository interface { LayananRepository LabRegistrationRepository + PatientRepository } func NewHandler(repo CPONERepository) Handler { - return Handler{layananRepo: repo, labRepo: repo} + return Handler{layananRepo: repo, labRepo: repo, patientRepo: repo} } func (h Handler) MappingHarga(w http.ResponseWriter, r *http.Request) { diff --git a/internal/http/medrec.go b/internal/http/medrec.go new file mode 100644 index 0000000..9b0defa --- /dev/null +++ b/internal/http/medrec.go @@ -0,0 +1,230 @@ +package http + +import ( + "errors" + "net/http" + "regexp" + "strings" + "time" + + "primaya-api/cpone-middleware/internal/repository" +) + +var ( + spaces = regexp.MustCompile(`\s+`) + nonAlphaNumber = regexp.MustCompile(`[^A-Za-z0-9]`) + nonDigit = regexp.MustCompile(`\D`) + ktpNumber = regexp.MustCompile(`^\d{16}$`) + emailAddress = regexp.MustCompile(`^[^\s@]+@[^\s@]+\.[^\s@]+$`) +) + +type MedrecResponse struct { + Success bool `json:"success"` + Status string `json:"status"` + Message string `json:"message"` + Data any `json:"data"` +} + +func (h Handler) StoreMedrec(w http.ResponseWriter, r *http.Request) { + payload, _ := decodeJSONObject(r) + params, message := validateMedrec(payload) + if message != "" { + writeMedrecResponse(w, http.StatusUnprocessableEntity, false, "validation_error", message, nil) + return + } + + outcome, err := h.patientRepo.StoreMedrec(r.Context(), params) + if err != nil { + var validationErr repository.ValidationError + if errors.As(err, &validationErr) { + writeMedrecResponse(w, http.StatusUnprocessableEntity, false, "validation_error", validationErr.Message, nil) + return + } + writeMedrecResponse(w, http.StatusInternalServerError, false, "error", err.Error(), nil) + return + } + + writeMedrecResponse(w, outcome.HTTPStatus, outcome.Success, outcome.Status, outcome.Message, outcome.Data) +} + +func validateMedrec(payload map[string]any) (repository.MedrecParams, string) { + rs, message := requiredString(payload, "rs", 20, "Kode RS wajib diisi") + if message != "" { + return repository.MedrecParams{}, message + } + name, message := requiredString(payload, "nama", 255, "Nama pasien wajib diisi") + if message != "" { + return repository.MedrecParams{}, message + } + gender, message := requiredString(payload, "jenis_kelamin", 0, "Jenis kelamin wajib diisi") + if message != "" { + return repository.MedrecParams{}, message + } + if gender != "L" && gender != "P" && gender != "l" && gender != "p" { + return repository.MedrecParams{}, "Jenis kelamin harus L atau P" + } + + dateOfBirth, message := requiredString(payload, "tgl_lahir", 0, "Tanggal lahir wajib diisi") + if message != "" { + return repository.MedrecParams{}, message + } + jakarta, err := time.LoadLocation("Asia/Jakarta") + if err != nil { + return repository.MedrecParams{}, err.Error() + } + birthDate, err := time.ParseInLocation("2006-01-02", dateOfBirth, jakarta) + if err != nil || birthDate.Format("2006-01-02") != dateOfBirth { + return repository.MedrecParams{}, "Tanggal lahir harus format Y-m-d" + } + now := time.Now().In(jakarta) + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, jakarta) + if birthDate.After(today) { + return repository.MedrecParams{}, "Tanggal lahir tidak boleh lebih dari hari ini" + } + + identityType, present, message := optionalString(payload, "jenis_nomer_sosial", 20) + if message != "" { + return repository.MedrecParams{}, message + } + if !present || identityType == "" { + identityType = "KTP" + } + identityNumber, identityPresent, message := optionalString(payload, "nomer_sosial", 50) + if message != "" { + return repository.MedrecParams{}, message + } + nik, nikPresent, message := optionalString(payload, "nik", 50) + if message != "" { + return repository.MedrecParams{}, message + } + if !identityPresent || identityNumber == "" { + if nikPresent { + identityNumber = nik + } + } + + phone, phonePresent, message := optionalString(payload, "telepon", 30) + if message != "" { + return repository.MedrecParams{}, message + } + mobile, mobilePresent, message := optionalString(payload, "ponsel", 30) + if message != "" { + return repository.MedrecParams{}, message + } + if !phonePresent && !mobilePresent { + return repository.MedrecParams{}, "Telepon atau ponsel wajib diisi" + } + + email, _, message := optionalString(payload, "email", 255) + if message != "" { + return repository.MedrecParams{}, message + } + if email != "" && !emailAddress.MatchString(strings.TrimSpace(email)) { + return repository.MedrecParams{}, "The email must be a valid email address." + } + + placeOfBirth, message := optionalMedrecString(payload, "tempat_lahir", 100) + if message != "" { + return repository.MedrecParams{}, message + } + address, message := optionalMedrecString(payload, "alamat", 500) + if message != "" { + return repository.MedrecParams{}, message + } + province, message := optionalMedrecString(payload, "propinsi", 50) + if message != "" { + return repository.MedrecParams{}, message + } + city, message := optionalMedrecString(payload, "kabupaten", 50) + if message != "" { + return repository.MedrecParams{}, message + } + district, message := optionalMedrecString(payload, "kecamatan", 50) + if message != "" { + return repository.MedrecParams{}, message + } + village, message := optionalMedrecString(payload, "kelurahan", 50) + if message != "" { + return repository.MedrecParams{}, message + } + postalCode, message := optionalMedrecString(payload, "kode_pos", 20) + if message != "" { + return repository.MedrecParams{}, message + } + companyID, message := optionalMedrecString(payload, "perusahaan_id", 50) + if message != "" { + return repository.MedrecParams{}, message + } + channel, channelPresent, message := optionalString(payload, "channel", 50) + if message != "" { + return repository.MedrecParams{}, message + } + if !channelPresent || channel == "" { + channel = "CPONE" + } + + identityType = normalizeText(identityType) + identityNumber = normalizeIdentity(identityNumber) + if identityNumber == "" { + return repository.MedrecParams{}, "NIK/nomer_sosial wajib diisi" + } + if identityType == "KTP" && !ktpNumber.MatchString(identityNumber) { + return repository.MedrecParams{}, "NIK/KTP harus 16 digit angka" + } + + phone = normalizePhone(phone) + mobile = normalizePhone(mobile) + if phone == "" && mobile == "" { + return repository.MedrecParams{}, "Telepon atau ponsel wajib diisi" + } + + return repository.MedrecParams{ + HospitalCode: normalizeText(rs), + Name: normalizeName(name), + Gender: normalizeText(gender), + DateOfBirth: birthDate, + IdentityType: identityType, + IdentityNumber: identityNumber, + Phone: phone, + Mobile: mobile, + Email: strings.ToLower(strings.TrimSpace(email)), + PlaceOfBirth: normalizeName(placeOfBirth), + Address: strings.TrimSpace(address), + Province: strings.TrimSpace(province), + City: strings.TrimSpace(city), + District: strings.TrimSpace(district), + Village: strings.TrimSpace(village), + PostalCode: strings.TrimSpace(postalCode), + CompanyID: strings.TrimSpace(companyID), + Channel: normalizeText(channel), + }, "" +} + +func optionalMedrecString(payload map[string]any, key string, maxLength int) (string, string) { + value, _, message := optionalString(payload, key, maxLength) + return value, message +} + +func normalizeName(value string) string { + return strings.ToUpper(strings.TrimSpace(spaces.ReplaceAllString(value, " "))) +} + +func normalizeText(value string) string { + return strings.ToUpper(strings.TrimSpace(value)) +} + +func normalizeIdentity(value string) string { + return strings.ToUpper(nonAlphaNumber.ReplaceAllString(value, "")) +} + +func normalizePhone(value string) string { + phone := nonDigit.ReplaceAllString(value, "") + if strings.HasPrefix(phone, "62") { + return "0" + strings.TrimPrefix(phone, "62") + } + return phone +} + +func writeMedrecResponse(w http.ResponseWriter, statusCode int, success bool, status, message string, data any) { + WriteJSON(w, statusCode, MedrecResponse{Success: success, Status: status, Message: message, Data: data}) +} diff --git a/internal/http/medrec_test.go b/internal/http/medrec_test.go new file mode 100644 index 0000000..798f218 --- /dev/null +++ b/internal/http/medrec_test.go @@ -0,0 +1,191 @@ +package http + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "primaya-api/cpone-middleware/internal/repository" +) + +type fakePatientRepository struct { + outcome repository.MedrecOutcome + err error + params repository.MedrecParams +} + +func (f *fakePatientRepository) StoreMedrec(_ context.Context, params repository.MedrecParams) (repository.MedrecOutcome, error) { + f.params = params + return f.outcome, f.err +} + +func TestStoreMedrecValidPayloadAndCreatedResponseParity(t *testing.T) { + name := "BUDI SANTOS" + birthDate := "1990-01-15" + identityType := "KTP" + identityNumber := "3174011501900001" + phone := "081234567890" + repo := &fakePatientRepository{outcome: repository.MedrecOutcome{ + HTTPStatus: http.StatusCreated, + Success: true, + Status: "created", + Message: "Pasien berhasil disimpan", + Data: repository.MedrecData{ + Patient: repository.MedrecPatient{ + MEDRECID: "00001235", + Nama: &name, + TanggalLahir: &birthDate, + JenisNomerSosial: &identityType, + NomerSosial: &identityNumber, + Telepon: &phone, + Ponsel: &phone, + }, + IsNewPatient: true, + }, + }} + handler := Handler{patientRepo: repo} + body := `{ + "rs":" primaya ", + "nama":" Budi Santos ", + "jenis_kelamin":"l", + "tgl_lahir":"1990-01-15", + "jenis_nomer_sosial":"ktp", + "nomer_sosial":"3174-0115-0190-0001", + "telepon":"+62 812-3456-7890", + "ponsel":"0812 3456 7890", + "email":" BUDI@Example.COM ", + "tempat_lahir":" jakarta ", + "alamat":" JL. CONTOH NO. 1 ", + "propinsi":" DKI JAKARTA ", + "kabupaten":" JAKARTA ", + "kecamatan":" KECAMATAN CONTOH ", + "kelurahan":" KELURAHAN CONTOH ", + "kode_pos":" 12345 ", + "channel":" cpone " + }` + + request := httptest.NewRequest(http.MethodPost, "/api/cpone/patients/medrec", strings.NewReader(body)) + recorder := httptest.NewRecorder() + handler.StoreMedrec(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":"created","message":"Pasien berhasil disimpan","data":{"patient":{"MEDRECID":"00001235","Nama":"BUDI SANTOS","TanggalLahir":"1990-01-15","JenisNomerSosial":"KTP","NomerSosial":"3174011501900001","Telepon":"081234567890","Ponsel":"081234567890"},"is_new_patient":true}}` + "\n" + if recorder.Body.String() != expected { + t.Fatalf("body mismatch\n got: %s\nwant: %s", recorder.Body.String(), expected) + } + + if repo.params.HospitalCode != "PRIMAYA" || repo.params.Name != "BUDI SANTOS" || repo.params.Gender != "L" { + t.Fatalf("basic normalization mismatch: %+v", repo.params) + } + if repo.params.IdentityType != "KTP" || repo.params.IdentityNumber != "3174011501900001" { + t.Fatalf("identity normalization mismatch: %+v", repo.params) + } + if repo.params.Phone != "081234567890" || repo.params.Mobile != "081234567890" { + t.Fatalf("phone normalization mismatch: %+v", repo.params) + } + if repo.params.Email != "budi@example.com" || repo.params.PlaceOfBirth != "JAKARTA" || repo.params.Channel != "CPONE" { + t.Fatalf("text normalization mismatch: %+v", repo.params) + } + if repo.params.DateOfBirth.Format("2006-01-02") != "1990-01-15" { + t.Fatalf("birth date mismatch: %s", repo.params.DateOfBirth) + } +} + +func TestStoreMedrecValidationParity(t *testing.T) { + baseline := map[string]any{ + "rs": "PRIMAYA", + "nama": "BUDI SANTOS", + "jenis_kelamin": "L", + "tgl_lahir": "1990-01-15", + "jenis_nomer_sosial": "KTP", + "nomer_sosial": "3174011501900001", + "telepon": "081234567890", + } + + tomorrow := time.Now().AddDate(0, 0, 1).Format("2006-01-02") + tests := []struct { + name string + mutate func(map[string]any) + message string + }{ + {name: "missing rs", mutate: func(p map[string]any) { delete(p, "rs") }, message: "Kode RS wajib diisi"}, + {name: "missing name", mutate: func(p map[string]any) { delete(p, "nama") }, message: "Nama pasien wajib diisi"}, + {name: "invalid gender", mutate: func(p map[string]any) { p["jenis_kelamin"] = "M" }, message: "Jenis kelamin harus L atau P"}, + {name: "invalid date format", mutate: func(p map[string]any) { p["tgl_lahir"] = "15-01-1990" }, message: "Tanggal lahir harus format Y-m-d"}, + {name: "future birth date", mutate: func(p map[string]any) { p["tgl_lahir"] = tomorrow }, message: "Tanggal lahir tidak boleh lebih dari hari ini"}, + {name: "missing identity", mutate: func(p map[string]any) { delete(p, "nomer_sosial") }, message: "NIK/nomer_sosial wajib diisi"}, + {name: "invalid ktp", mutate: func(p map[string]any) { p["nomer_sosial"] = "123" }, message: "NIK/KTP harus 16 digit angka"}, + {name: "missing phone", mutate: func(p map[string]any) { delete(p, "telepon") }, message: "Telepon atau ponsel wajib diisi"}, + {name: "invalid email", mutate: func(p map[string]any) { p["email"] = "invalid" }, message: "The email must be a valid email address."}, + {name: "long hospital code", mutate: func(p map[string]any) { p["rs"] = strings.Repeat("X", 21) }, message: "The rs must not be greater than 20 characters."}, + } + + 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{patientRepo: &fakePatientRepository{}} + request := httptest.NewRequest(http.MethodPost, "/api/cpone/patients/medrec", strings.NewReader(string(body))) + recorder := httptest.NewRecorder() + handler.StoreMedrec(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 TestStoreMedrecNikFallbackAndOutcomeForwarding(t *testing.T) { + data := repository.PossibleDuplicateData{Candidates: []repository.MedrecPatient{}} + repo := &fakePatientRepository{outcome: repository.MedrecOutcome{ + HTTPStatus: http.StatusConflict, + Success: false, + Status: "possible_duplicate", + Message: "Ditemukan pasien dengan nama, tanggal lahir, dan nomor telepon yang sama atau sangat mirip", + Data: data, + }} + handler := Handler{patientRepo: repo} + body := `{"rs":"PRIMAYA","nama":"BUDI","jenis_kelamin":"P","tgl_lahir":"1990-01-15","nik":"3174011501900001","ponsel":"08123"}` + request := httptest.NewRequest(http.MethodPost, "/api/cpone/patients/medrec", strings.NewReader(body)) + recorder := httptest.NewRecorder() + handler.StoreMedrec(recorder, request) + + if recorder.Code != http.StatusConflict || repo.params.IdentityType != "KTP" || repo.params.IdentityNumber != "3174011501900001" { + t.Fatalf("unexpected result: status=%d params=%+v body=%s", recorder.Code, repo.params, recorder.Body.String()) + } +} + +func TestStoreMedrecRepositoryErrorParity(t *testing.T) { + handler := Handler{patientRepo: &fakePatientRepository{err: errors.New("database unavailable")}} + body := `{"rs":"PRIMAYA","nama":"BUDI","jenis_kelamin":"L","tgl_lahir":"1990-01-15","nomer_sosial":"3174011501900001","telepon":"08123"}` + request := httptest.NewRequest(http.MethodPost, "/api/cpone/patients/medrec", strings.NewReader(body)) + recorder := httptest.NewRecorder() + handler.StoreMedrec(recorder, request) + + if recorder.Code != http.StatusInternalServerError { + t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String()) + } + const expected = `{"success":false,"status":"error","message":"database unavailable","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/repository/medrec.go b/internal/repository/medrec.go new file mode 100644 index 0000000..2c14627 --- /dev/null +++ b/internal/repository/medrec.go @@ -0,0 +1,310 @@ +package repository + +import ( + "context" + "database/sql" + "strconv" + "strings" + "time" +) + +type MedrecParams struct { + HospitalCode string + Name string + Gender string + DateOfBirth time.Time + IdentityType string + IdentityNumber string + Phone string + Mobile string + Email string + PlaceOfBirth string + Address string + Province string + City string + District string + Village string + PostalCode string + CompanyID string + Channel string +} + +type MedrecOutcome struct { + HTTPStatus int + Success bool + Status string + Message string + Data any +} + +type MedrecData struct { + Patient MedrecPatient `json:"patient"` + IsNewPatient bool `json:"is_new_patient"` +} + +type PossibleDuplicateData struct { + Candidates []MedrecPatient `json:"candidates"` +} + +type MedrecPatient struct { + MEDRECID string `json:"MEDRECID"` + Nama *string `json:"Nama"` + TanggalLahir *string `json:"TanggalLahir"` + JenisNomerSosial *string `json:"JenisNomerSosial"` + NomerSosial *string `json:"NomerSosial"` + Telepon *string `json:"Telepon"` + Ponsel *string `json:"Ponsel"` +} + +func (r MySQLLayananRepository) StoreMedrec(ctx context.Context, params MedrecParams) (MedrecOutcome, error) { + var lastErr error + for attempt := 0; attempt < 3; attempt++ { + outcome, err := r.storeMedrecAttempt(ctx, params) + if err == nil { + return outcome, nil + } + lastErr = err + if !isConcurrencyError(err) || ctx.Err() != nil { + return MedrecOutcome{}, err + } + } + return MedrecOutcome{}, lastErr +} + +func (r MySQLLayananRepository) storeMedrecAttempt(ctx context.Context, params MedrecParams) (outcome MedrecOutcome, err error) { + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return MedrecOutcome{}, err + } + defer func() { + if recovered := recover(); recovered != nil { + _ = tx.Rollback() + panic(recovered) + } + if err != nil { + _ = tx.Rollback() + } + }() + + outcome, err = storeMedrecInTransaction(ctx, tx, params) + if err != nil { + return MedrecOutcome{}, err + } + if err = tx.Commit(); err != nil { + return MedrecOutcome{}, err + } + return outcome, nil +} + +func storeMedrecInTransaction(ctx context.Context, tx *sql.Tx, params MedrecParams) (MedrecOutcome, error) { + patient, found, err := findMedrecByIdentity(ctx, tx, params.IdentityType, params.IdentityNumber) + if err != nil { + return MedrecOutcome{}, err + } + if found { + return MedrecOutcome{ + HTTPStatus: 200, + Success: true, + Status: "already_exists", + Message: "Pasien sudah terdaftar", + Data: MedrecData{Patient: patient, IsNewPatient: false}, + }, nil + } + + duplicatePhone := params.Mobile + if duplicatePhone == "" { + duplicatePhone = params.Phone + } + candidates, err := findPossibleMedrecDuplicates(ctx, tx, params.Name, params.DateOfBirth, duplicatePhone) + if err != nil { + return MedrecOutcome{}, err + } + if len(candidates) > 0 { + return MedrecOutcome{ + HTTPStatus: 409, + Success: false, + Status: "possible_duplicate", + Message: "Ditemukan pasien dengan nama, tanggal lahir, dan nomor telepon yang sama atau sangat mirip", + Data: PossibleDuplicateData{Candidates: candidates}, + }, nil + } + + medrecID, err := nextMedrecID(ctx, tx) + if err != nil { + return MedrecOutcome{}, err + } + patient, err = insertMedrec(ctx, tx, medrecID, params) + if err != nil { + return MedrecOutcome{}, err + } + + return MedrecOutcome{ + HTTPStatus: 201, + Success: true, + Status: "created", + Message: "Pasien berhasil disimpan", + Data: MedrecData{Patient: patient, IsNewPatient: true}, + }, nil +} + +func findMedrecByIdentity(ctx context.Context, tx *sql.Tx, identityType, identityNumber string) (MedrecPatient, bool, error) { + row := tx.QueryRowContext(ctx, medrecPatientSelect+` + WHERE JenisNomerSosial = ? AND NomerSosial = ? + LIMIT 1 FOR UPDATE`, identityType, identityNumber) + patient, err := scanMedrecPatient(row) + if err == sql.ErrNoRows { + return MedrecPatient{}, false, nil + } + return patient, err == nil, err +} + +func findPossibleMedrecDuplicates(ctx context.Context, tx *sql.Tx, name string, birthDate time.Time, phone string) ([]MedrecPatient, error) { + rows, err := tx.QueryContext(ctx, medrecPatientSelect+` + WHERE UPPER(TRIM(Nama)) = ? AND DATE(TanggalLahir) = ?`, name, birthDate.Format("2006-01-02")) + if err != nil { + return nil, err + } + defer rows.Close() + + candidates := make([]MedrecPatient, 0) + for rows.Next() { + patient, err := scanMedrecPatient(rows) + if err != nil { + return nil, err + } + if phone == normalizeMedrecPhone(pointerValue(patient.Telepon)) || phone == normalizeMedrecPhone(pointerValue(patient.Ponsel)) { + candidates = append(candidates, patient) + if len(candidates) == 5 { + break + } + } + } + if err := rows.Err(); err != nil { + return nil, err + } + return candidates, nil +} + +const medrecPatientSelect = ` + SELECT MEDRECID, Nama, DATE_FORMAT(TanggalLahir, '%Y-%m-%d'), + JenisNomerSosial, NomerSosial, Telepon, Ponsel + FROM medrec` + +type rowScanner interface { + Scan(dest ...any) error +} + +func scanMedrecPatient(row rowScanner) (MedrecPatient, error) { + var patient MedrecPatient + var name, birthDate, identityType, identityNumber, phone, mobile sql.NullString + err := row.Scan(&patient.MEDRECID, &name, &birthDate, &identityType, &identityNumber, &phone, &mobile) + if err != nil { + return MedrecPatient{}, err + } + patient.Nama = nullStringPtr(name) + patient.TanggalLahir = nullStringPtr(birthDate) + patient.JenisNomerSosial = nullStringPtr(identityType) + patient.NomerSosial = nullStringPtr(identityNumber) + patient.Telepon = nullStringPtr(phone) + patient.Ponsel = nullStringPtr(mobile) + return patient, nil +} + +func nextMedrecID(ctx context.Context, tx *sql.Tx) (string, error) { + var lastID string + err := tx.QueryRowContext(ctx, `SELECT MEDRECID FROM medrec ORDER BY MEDRECID DESC LIMIT 1 FOR UPDATE`).Scan(&lastID) + if err == sql.ErrNoRows { + return "00000001", nil + } + if err != nil { + return "", err + } + number, err := strconv.ParseUint(lastID, 10, 64) + if err != nil { + return "", err + } + return leftPadNumber(number+1, len(lastID)), nil +} + +func insertMedrec(ctx context.Context, tx *sql.Tx, medrecID string, params MedrecParams) (MedrecPatient, error) { + phone := params.Phone + if phone == "" { + phone = params.Mobile + } + mobile := params.Mobile + if mobile == "" { + mobile = params.Phone + } + var nik any + if params.IdentityType == "KTP" { + nik = params.IdentityNumber + } + firstName := params.Name + if index := strings.IndexByte(params.Name, ' '); index >= 0 { + firstName = params.Name[:index] + } + jakarta, err := time.LoadLocation("Asia/Jakarta") + if err != nil { + return MedrecPatient{}, err + } + now := time.Now().In(jakarta).Format("2006-01-02 15:04:05") + + _, err = tx.ExecContext(ctx, ` + INSERT INTO medrec ( + MEDRECID, KodeRS, Nama, Panggilan, Kelamin, TanggalLahir, TempatLahir, + JenisNomerSosial, NomerSosial, NIK, Telepon, Ponsel, Email, Alamat, + Propinsi, Kota, Kecamatan, Kelurahan, KodePos, PerusahaanID, + Mati, NA, LoginBuat, TanggalBuat, TanggalEdit + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'N', 'N', ?, ?, ?)`, + medrecID, params.HospitalCode, params.Name, firstName, params.Gender, + params.DateOfBirth.Format("2006-01-02"), params.PlaceOfBirth, + params.IdentityType, params.IdentityNumber, nik, phone, mobile, params.Email, + params.Address, params.Province, params.City, params.District, params.Village, + params.PostalCode, params.CompanyID, params.Channel, now, now) + if err != nil { + return MedrecPatient{}, err + } + + return MedrecPatient{ + MEDRECID: medrecID, + Nama: stringPtr(params.Name), + TanggalLahir: stringPtr(params.DateOfBirth.Format("2006-01-02")), + JenisNomerSosial: stringPtr(params.IdentityType), + NomerSosial: stringPtr(params.IdentityNumber), + Telepon: stringPtr(phone), + Ponsel: stringPtr(mobile), + }, nil +} + +func leftPadNumber(number uint64, width int) string { + value := strconv.FormatUint(number, 10) + if len(value) >= width { + return value + } + return strings.Repeat("0", width-len(value)) + value +} + +func normalizeMedrecPhone(value string) string { + var builder strings.Builder + for _, char := range value { + if char >= '0' && char <= '9' { + builder.WriteRune(char) + } + } + phone := builder.String() + if strings.HasPrefix(phone, "62") { + return "0" + phone[2:] + } + return phone +} + +func pointerValue(value *string) string { + if value == nil { + return "" + } + return *value +} + +func stringPtr(value string) *string { + return &value +}