package repository import ( "context" "database/sql" "errors" "fmt" "strconv" "strings" "time" ) type paketServiceReference struct { LayananDepartemenID int64 Module string } func (r MySQLLayananRepository) CreatePaket(ctx context.Context, params CreatePaketParams) (result CreatePaketResult, err error) { conn, err := r.db.Conn(ctx) if err != nil { return CreatePaketResult{}, err } defer conn.Close() tx, err := conn.BeginTx(ctx, nil) if err != nil { return CreatePaketResult{}, err } packageID := "" createdDepartments := make([]int64, 0) lockName := "" lockHeld := false defer func() { if recovered := recover(); recovered != nil { _ = tx.Rollback() panic(recovered) } if err != nil { // Some HIS installations still use MyISAM for these tables. Explicit // cleanup makes the operation compensating when ROLLBACK is ineffective. if packageID != "" { _, _ = tx.ExecContext(ctx, `DELETE FROM paket_layanan WHERE PaketID = ?`, packageID) for _, departmentID := range createdDepartments { _, _ = tx.ExecContext(ctx, `DELETE FROM paket_departemen WHERE PaketDepartemenID = ?`, departmentID) } _, _ = tx.ExecContext(ctx, `DELETE FROM paket_header WHERE PaketID = ?`, packageID) } _ = tx.Rollback() } if lockHeld { _, _ = conn.ExecContext(ctx, `SELECT RELEASE_LOCK(?)`, lockName) } }() nowLocation, locationErr := time.LoadLocation("Asia/Jakarta") if locationErr != nil { return CreatePaketResult{}, locationErr } nowTime := time.Now().In(nowLocation) now := nowTime.Format("2006-01-02 15:04:05") prefix := "DISP-" + nowTime.Format("0601") lockName = "cpone:create-paket:" + prefix var lockResult int if err = conn.QueryRowContext(ctx, `SELECT GET_LOCK(?, 10)`, lockName).Scan(&lockResult); err != nil { return CreatePaketResult{}, err } if lockResult != 1 { return CreatePaketResult{}, ValidationError{Message: "Pembuatan paket sedang diproses, silakan coba lagi"} } lockHeld = true if err = validatePaketReferences(ctx, tx, params); err != nil { return CreatePaketResult{}, err } var duplicate int err = tx.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM paket_header WHERE LOWER(TRIM(Nama)) = LOWER(TRIM(?)) AND NA = 'N')`, params.Nama).Scan(&duplicate) if err != nil { return CreatePaketResult{}, err } if duplicate != 0 { return CreatePaketResult{}, ValidationError{Message: "Nama paket sudah digunakan"} } packageID, err = nextPaketID(ctx, tx, prefix) if err != nil { return CreatePaketResult{}, err } _, err = tx.ExecContext(ctx, ` INSERT INTO paket_header ( PaketID, Nama, JenisPaket, RJ, RI, MultiVisit, TglDari, TglSampai, HeaderPaket, KelasID, JumlahHari, TotalHarga, Keterangan, StatusID, LoginBuat, TanggalBuat, NA ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 2, ?, ?, 'N')`, packageID, params.Nama, params.JenisPaket, boolInt(params.RawatJalan), boolInt(params.RawatInap), boolInt(params.MultiVisit), nullableDate(params.TanggalMulai), nullableDate(params.TanggalSelesai), params.HeaderPaketID, params.KelasID, params.JumlahHari, "0.00", params.Keterangan, params.Channel, now) if err != nil { return CreatePaketResult{}, err } totalPrice := int64(0) totalDiscount := int64(0) for _, service := range params.Layanan { departmentID, created, serviceErr := ensurePaketDepartment(ctx, tx, packageID, service.DepartemenID) if serviceErr != nil { return CreatePaketResult{}, serviceErr } if created { createdDepartments = append(createdDepartments, departmentID) } discountAmount := service.DiskonCents if service.TipeDiskon == "Percentage" { discountAmount = service.HargaCents * service.DiskonCents / 10000 } subtotal := (service.HargaCents - discountAmount) * service.Jumlah totalPrice += subtotal totalDiscount += discountAmount * service.Jumlah _, err = tx.ExecContext(ctx, ` INSERT INTO paket_layanan ( PaketID, PaketDepartemenID, DepartemenID, LayananID, HeaderLayanan, Jumlah, JumlahHari, Harga, TipeDiskon, Diskon, ShareTipe, ShareRS, ShareDokter, ShareExternal, ShareLain, Subtotal, LoginBuat, TanggalBuat, NA ) VALUES (?, ?, ?, ?, '', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'N')`, packageID, departmentID, service.DepartemenID, service.LayananID, service.Jumlah, service.JumlahHari, decimalText(service.HargaCents), service.TipeDiskon, decimalText(service.DiskonCents), service.TipeShare, decimalText(service.ShareRSCents), decimalText(service.ShareDokterCents), decimalText(service.ShareExternalCents), decimalText(service.ShareLainCents), decimalText(subtotal), params.Channel, now) if err != nil { return CreatePaketResult{}, err } } _, err = tx.ExecContext(ctx, `UPDATE paket_header SET TotalHarga = ? WHERE PaketID = ?`, decimalText(totalPrice), packageID) if err != nil { return CreatePaketResult{}, err } if err = tx.Commit(); err != nil { return CreatePaketResult{}, err } if lockHeld { _, _ = conn.ExecContext(ctx, `SELECT RELEASE_LOCK(?)`, lockName) lockHeld = false } return CreatePaketResult{ PaketID: packageID, Nama: params.Nama, TotalHarga: decimalText(totalPrice), TotalDiskon: decimalText(totalDiscount), JumlahLayanan: len(params.Layanan), }, nil } func validatePaketReferences(ctx context.Context, tx *sql.Tx, params CreatePaketParams) error { var found int if err := tx.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM jenispaket WHERE JenisPaketID = ?)`, params.JenisPaket).Scan(&found); err != nil { return err } if found == 0 { return ValidationError{Message: "Jenis paket tidak ditemukan"} } if err := tx.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM kelas WHERE KelasID = ? AND NA = 'N')`, params.KelasID).Scan(&found); err != nil { return err } if found == 0 { return ValidationError{Message: "Kelas paket tidak ditemukan atau tidak aktif"} } for _, service := range params.Layanan { ref, err := findPaketServiceReference(ctx, tx, service.LayananID, service.DepartemenID) if err != nil { return err } packageType := strings.ToUpper(strings.TrimSpace(params.JenisPaket)) module := strings.ToUpper(ref.Module) if packageType == "LAB" && !strings.Contains(module, "LAB") { return ValidationError{Message: fmt.Sprintf("Departemen %s bukan departemen laboratorium", service.DepartemenID)} } if packageType == "RAD" && !strings.Contains(module, "RAD") { return ValidationError{Message: fmt.Sprintf("Departemen %s bukan departemen radiologi", service.DepartemenID)} } if (packageType == "LAB" || packageType == "RAD") && service.JumlahHari > 1 { return ValidationError{Message: "Jumlah hari layanan paket LAB/RAD maksimal 1"} } } return nil } func findPaketServiceReference(ctx context.Context, tx *sql.Tx, serviceID, departmentID string) (paketServiceReference, error) { var ref paketServiceReference err := tx.QueryRowContext(ctx, ` SELECT 0, d.NamaModulExternal FROM masterlayanan ml INNER JOIN departemen d ON d.DepartemenID = ? WHERE ml.LayananID = ? LIMIT 1`, departmentID, serviceID).Scan(&ref.LayananDepartemenID, &ref.Module) if errors.Is(err, sql.ErrNoRows) { return paketServiceReference{}, ValidationError{Message: fmt.Sprintf("Layanan %s atau departemen %s tidak ditemukan", serviceID, departmentID)} } if err != nil { return paketServiceReference{}, err } return ref, nil } func ensurePaketDepartment(ctx context.Context, tx *sql.Tx, packageID, departmentID string) (int64, bool, error) { var id int64 err := tx.QueryRowContext(ctx, ` SELECT PaketDepartemenID FROM paket_departemen WHERE PaketID = ? AND DepartemenID = ? AND NA = 'N' ORDER BY PaketDepartemenID LIMIT 1 FOR UPDATE`, packageID, departmentID).Scan(&id) if err == nil { return id, false, nil } if !errors.Is(err, sql.ErrNoRows) { return 0, false, err } result, err := tx.ExecContext(ctx, ` INSERT INTO paket_departemen (PaketID, DepartemenID, NA) VALUES (?, ?, 'N')`, packageID, departmentID) if err != nil { return 0, false, err } id, err = result.LastInsertId() if err != nil { return 0, false, err } return id, true, nil } func nextPaketID(ctx context.Context, tx *sql.Tx, prefix string) (string, error) { var lastID sql.NullString err := tx.QueryRowContext(ctx, ` SELECT PaketID FROM paket_header WHERE PaketID LIKE ? ORDER BY PaketID DESC LIMIT 1 FOR UPDATE`, prefix+"%").Scan(&lastID) if errors.Is(err, sql.ErrNoRows) || !lastID.Valid { return prefix + "00001", nil } if err != nil { return "", err } sequence, err := strconv.Atoi(strings.TrimPrefix(lastID.String, prefix)) if err != nil { return "", fmt.Errorf("format PaketID terakhir tidak valid: %w", err) } return fmt.Sprintf("%s%05d", prefix, sequence+1), nil } func boolInt(value bool) int { if value { return 1 } return 0 } func nullableDate(value string) any { if strings.TrimSpace(value) == "" { return nil } return value } func decimalText(cents int64) string { return fmt.Sprintf("%d.%02d", cents/100, cents%100) }