feat(cpone): migrate medrec endpoint
This commit is contained in:
31
README.md
31
README.md
@@ -13,10 +13,41 @@ Content-Type: application/json
|
|||||||
POST /api/cpone/patients/lab-registration/{regId}/services
|
POST /api/cpone/patients/lab-registration/{regId}/services
|
||||||
Authorization: Bearer <your-secure-token>
|
Authorization: Bearer <your-secure-token>
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
|
|
||||||
|
POST /api/cpone/patients/medrec
|
||||||
|
Authorization: Bearer <your-secure-token>
|
||||||
|
Content-Type: application/json
|
||||||
```
|
```
|
||||||
|
|
||||||
Format payload dan response dibuat mengikuti controller PHP CPONE terkait.
|
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:
|
Contoh payload registrasi laboratorium:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ func main() {
|
|||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.Handle("GET /api/cpone/layanan-mapping-harga", apphttp.BearerAuth(cfg.BearerToken, http.HandlerFunc(handler.MappingHarga)))
|
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", apphttp.BearerAuth(cfg.BearerToken, http.HandlerFunc(handler.RegisterLab)))
|
||||||
mux.Handle("POST /api/cpone/patients/lab-registration/{regId}/services", apphttp.BearerAuth(cfg.BearerToken, http.HandlerFunc(handler.AddLabServices)))
|
mux.Handle("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) {
|
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -21,18 +21,24 @@ type LabRegistrationRepository interface {
|
|||||||
AddLabServices(ctx context.Context, regID string, params repository.AddLabServicesParams) (repository.LabRegistrationOutcome, error)
|
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 {
|
type Handler struct {
|
||||||
layananRepo LayananRepository
|
layananRepo LayananRepository
|
||||||
labRepo LabRegistrationRepository
|
labRepo LabRegistrationRepository
|
||||||
|
patientRepo PatientRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
type CPONERepository interface {
|
type CPONERepository interface {
|
||||||
LayananRepository
|
LayananRepository
|
||||||
LabRegistrationRepository
|
LabRegistrationRepository
|
||||||
|
PatientRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHandler(repo CPONERepository) Handler {
|
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) {
|
func (h Handler) MappingHarga(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
230
internal/http/medrec.go
Normal file
230
internal/http/medrec.go
Normal 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})
|
||||||
|
}
|
||||||
191
internal/http/medrec_test.go
Normal file
191
internal/http/medrec_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
310
internal/repository/medrec.go
Normal file
310
internal/repository/medrec.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user