feat(cpone): migrate lab registration endpoints

This commit is contained in:
2026-08-18 14:14:49 +07:00
parent 676d930561
commit a799022bb5
11 changed files with 2590 additions and 7 deletions

View File

@@ -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")),

View File

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

View File

@@ -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: &registrationType,
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)
}

View File

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

View File

@@ -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: &registrationType,
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)
}
})
}
}

View File

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

View File

@@ -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(
&registration.RegID,
&registration.MEDRECID,
&registration.Tanggal,
&registration.DepartemenID,
&registration.DokterID,
&registration.JenisPasienID,
&registration.JenisRegID,
&registration.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(
&registration.RegID,
&registration.MEDRECID,
&registration.Tanggal,
&registration.DepartemenID,
&registration.DokterID,
&registration.JenisPasienID,
&registration.JenisRegID,
&registration.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")
}

View File

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

View File

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