feat(cpone): migrate medrec endpoint

This commit is contained in:
2026-08-18 15:04:14 +07:00
parent a799022bb5
commit 13bbdc1328
6 changed files with 770 additions and 1 deletions

View File

@@ -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) {

230
internal/http/medrec.go Normal file
View File

@@ -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})
}

View File

@@ -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)
}
}