feat: add package creation and clone validation
This commit is contained in:
@@ -22,6 +22,47 @@ type PaketListParams struct {
|
||||
PerPage int
|
||||
}
|
||||
|
||||
// CreatePaketParams contains values already validated by the HTTP layer.
|
||||
// Monetary values are stored as cents to keep calculations exact without float.
|
||||
type CreatePaketParams struct {
|
||||
Nama string
|
||||
JenisPaket string
|
||||
RawatInap bool
|
||||
RawatJalan bool
|
||||
MultiVisit bool
|
||||
TanggalMulai string
|
||||
TanggalSelesai string
|
||||
KelasID string
|
||||
HeaderPaketID string
|
||||
Keterangan string
|
||||
JumlahHari int64
|
||||
Channel string
|
||||
Layanan []CreatePaketLayananParams
|
||||
}
|
||||
|
||||
type CreatePaketLayananParams struct {
|
||||
LayananID string
|
||||
DepartemenID string
|
||||
Jumlah int64
|
||||
JumlahHari int64
|
||||
HargaCents int64
|
||||
TipeDiskon string
|
||||
DiskonCents int64
|
||||
TipeShare string
|
||||
ShareRSCents int64
|
||||
ShareDokterCents int64
|
||||
ShareExternalCents int64
|
||||
ShareLainCents int64
|
||||
}
|
||||
|
||||
type CreatePaketResult struct {
|
||||
PaketID string
|
||||
Nama string
|
||||
TotalHarga string
|
||||
TotalDiskon string
|
||||
JumlahLayanan int
|
||||
}
|
||||
|
||||
type PaketPage struct {
|
||||
Items []PaketSummary
|
||||
Total int
|
||||
@@ -86,6 +127,7 @@ type PaketPeriodeTarif struct {
|
||||
type PaketLayanan struct {
|
||||
PaketLayananID int64 `json:"PaketLayananID"`
|
||||
LayananID string `json:"LayananID"`
|
||||
DepartemenID string `json:"DepartemenID"`
|
||||
NamaLayanan *string `json:"NamaLayanan"`
|
||||
NamaDepartemen *string `json:"NamaDepartemen"`
|
||||
Jumlah string `json:"Jumlah"`
|
||||
@@ -95,8 +137,11 @@ type PaketLayanan struct {
|
||||
HargaDasarSource string `json:"HargaDasarSource"`
|
||||
Diskon string `json:"Diskon"`
|
||||
TipeDiskon string `json:"TipeDiskon"`
|
||||
TipeShare string `json:"TipeShare"`
|
||||
ShareRS string `json:"ShareRS"`
|
||||
ShareDokter string `json:"ShareDokter"`
|
||||
ShareExternal string `json:"ShareExternal"`
|
||||
ShareLain string `json:"ShareLain"`
|
||||
Subtotal string `json:"Subtotal"`
|
||||
}
|
||||
|
||||
@@ -350,10 +395,11 @@ func (r MySQLLayananRepository) paketLayanan(
|
||||
}
|
||||
args = append(args, id)
|
||||
query := fmt.Sprintf(`
|
||||
SELECT pl.PaketLayananID, pl.LayananID, ml.Nama, d.Nama,
|
||||
SELECT pl.PaketLayananID, pl.LayananID, pl.DepartemenID, ml.Nama, d.Nama,
|
||||
CAST(pl.Jumlah AS CHAR), pl.JumlahHari, CAST(pl.Harga AS CHAR),
|
||||
CAST(pl.Diskon AS CHAR), pl.TipeDiskon, CAST(pl.ShareRS AS CHAR),
|
||||
CAST(pl.ShareDokter AS CHAR), CAST(pl.Subtotal AS CHAR),
|
||||
CAST(pl.ShareDokter AS CHAR), pl.ShareTipe, CAST(pl.ShareExternal AS CHAR),
|
||||
CAST(pl.ShareLain AS CHAR), CAST(pl.Subtotal AS CHAR),
|
||||
%s, COALESCE(ml.DefaultHarga, 0), COALESCE(ml.LayananKode, ''),
|
||||
CAST(pl.HeaderLayanan AS CHAR)
|
||||
FROM paket_layanan pl
|
||||
@@ -378,21 +424,26 @@ func (r MySQLLayananRepository) paketLayanan(
|
||||
for rows.Next() {
|
||||
var item PaketLayanan
|
||||
var namaLayanan, namaDepartemen sql.NullString
|
||||
var harga, shareRS, shareDokter, subtotal string
|
||||
var harga, shareRS, shareDokter, tipeShare, shareExternal, shareLain, subtotal string
|
||||
var departemenID string
|
||||
var servicePrice servicePricing
|
||||
if err := rows.Scan(
|
||||
&item.PaketLayananID, &item.LayananID, &namaLayanan, &namaDepartemen,
|
||||
&item.PaketLayananID, &item.LayananID, &departemenID, &namaLayanan, &namaDepartemen,
|
||||
&item.Jumlah, &item.JumlahHari, &harga, &item.Diskon, &item.TipeDiskon,
|
||||
&shareRS, &shareDokter, &subtotal, &servicePrice.tariffPrice,
|
||||
&shareRS, &shareDokter, &tipeShare, &shareExternal, &shareLain, &subtotal, &servicePrice.tariffPrice,
|
||||
&servicePrice.defaultPrice, &servicePrice.serviceCode, &servicePrice.operationHeader,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.NamaLayanan = nullStringPtr(namaLayanan)
|
||||
item.NamaDepartemen = nullStringPtr(namaDepartemen)
|
||||
item.DepartemenID = departemenID
|
||||
item.TipeShare = tipeShare
|
||||
item.Harga = phpNumberFormat(parseDecimal(harga), 0)
|
||||
item.ShareRS = phpNumberFormat(parseDecimal(shareRS), 2)
|
||||
item.ShareDokter = phpNumberFormat(parseDecimal(shareDokter), 0)
|
||||
item.ShareExternal = phpNumberFormat(parseDecimal(shareExternal), 2)
|
||||
item.ShareLain = phpNumberFormat(parseDecimal(shareLain), 2)
|
||||
item.Subtotal = phpNumberFormat(parseDecimal(subtotal), 0)
|
||||
if servicePrice.tariffPrice.Float64 <= 0 && servicePrice.defaultPrice <= 0 && isAdministrationService(servicePrice.serviceCode) {
|
||||
needsAdministrationPrice = true
|
||||
|
||||
260
internal/repository/paket_create.go
Normal file
260
internal/repository/paket_create.go
Normal file
@@ -0,0 +1,260 @@
|
||||
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, '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)
|
||||
}
|
||||
18
internal/repository/paket_create_test.go
Normal file
18
internal/repository/paket_create_test.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package repository
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDecimalText(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
cents int64
|
||||
want string
|
||||
}{
|
||||
{cents: 13000000, want: "130000.00"},
|
||||
{cents: 1, want: "0.01"},
|
||||
{cents: 0, want: "0.00"},
|
||||
} {
|
||||
if got := decimalText(test.cents); got != test.want {
|
||||
t.Fatalf("decimalText(%d)=%q want=%q", test.cents, got, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user