package kfa import ( "context" "encoding/json" "reflect" "strings" "unicode" "service/internal/infrastructure/database" "service/pkg/utils/query" "github.com/jmoiron/sqlx" ) type CommandRepository interface { UpsertProductBundle(ctx context.Context, product *KfaProduct, ingredients []KfaProductActiveIngredient, packagings []KfaProductPackaging) error } type commandRepository struct { dbManager database.Service connName string } func NewCommandRepository(dbManager database.Service, connName string) CommandRepository { return &commandRepository{ dbManager: dbManager, connName: connName, } } func (r *commandRepository) UpsertProductBundle(ctx context.Context, product *KfaProduct, ingredients []KfaProductActiveIngredient, packagings []KfaProductPackaging) (err error) { dbGorm, err := r.dbManager.GetGormDB(r.connName) if err != nil { return err } sqldb, err := dbGorm.DB() if err != nil { return err } dbx := sqlx.NewDb(sqldb, "postgres") // Inisialisasi Query Builder (Nonaktifkan limit sekuritas untuk internal worker) sqlBuilder := query.NewSQLQueryBuilder(query.DBTypePostgreSQL). SetQueryLogging(false). SetSecurityOptions(false, 0) // Ekstrak semua kolom yang akan digunakan untuk mendaftarkannya ke whitelist Query Builder productData, productUpdateCols := extractInsertData(product, "kfa_code") var allowedCols []string allowedCols = append(allowedCols, productData.Columns...) if len(ingredients) > 0 { ingData, _ := extractInsertData(ingredients[0], "") allowedCols = append(allowedCols, ingData.Columns...) } if len(packagings) > 0 { pkgData, _ := extractInsertData(packagings[0], "") allowedCols = append(allowedCols, pkgData.Columns...) } allowedCols = append(allowedCols, "product_kfa_code", "kfa_code") // pastikan key relasi / conflict dimasukkan // Daftarkan kolom agar terhindar dari error 'disallowed column' sqlBuilder.SetAllowedColumns(allowedCols) tx, err := dbx.BeginTxx(ctx, nil) if err != nil { return err } // Tangani Rollback jika terjadi kegagalan atau panic defer func() { if p := recover(); p != nil { tx.Rollback() panic(p) } else if err != nil { tx.Rollback() } }() // 1. Upsert Induk Product (Manual Check & Update/Insert to avoid PG Constraint Error 42P10) var count int err = tx.GetContext(ctx, &count, "SELECT count(*) FROM master.kfa_products WHERE kfa_code = $1", product.KfaCode) if err != nil { return err } if count > 0 { updateData := query.UpdateData{ Columns: productUpdateCols, Values: make([]interface{}, len(productUpdateCols)), } for i, col := range productUpdateCols { for j, insertCol := range productData.Columns { if col == insertCol { updateData.Values[i] = productData.Values[j] break } } } _, err = sqlBuilder.ExecuteUpdate(ctx, tx, "master.kfa_products", updateData, []query.FilterGroup{{ Filters: []query.DynamicFilter{query.CreateEqualFilter("kfa_code", product.KfaCode)}, }}) } else { _, err = sqlBuilder.ExecuteInsert(ctx, tx, "master.kfa_products", productData) } if err != nil { return err } // 2. Hapus Active Ingredients Lama delFilters := []query.FilterGroup{{ Filters: []query.DynamicFilter{ query.CreateEqualFilter("product_kfa_code", product.KfaCode), }, }} _, err = sqlBuilder.ExecuteDelete(ctx, tx, "master.kfa_product_active_ingredients", delFilters) if err != nil { return err } // 3. Insert Active Ingredients Baru for _, ing := range ingredients { ingData, _ := extractInsertData(ing, "") _, err = sqlBuilder.ExecuteInsert(ctx, tx, "master.kfa_product_active_ingredients", ingData) if err != nil { return err } } // 4. Hapus Packagings Lama _, err = sqlBuilder.ExecuteDelete(ctx, tx, "master.kfa_product_packagings", delFilters) if err != nil { return err } // 5. Insert Packagings Baru for _, pkg := range packagings { pkgData, _ := extractInsertData(pkg, "") _, err = sqlBuilder.ExecuteInsert(ctx, tx, "master.kfa_product_packagings", pkgData) if err != nil { return err } } err = tx.Commit() return err } // Helper: Ekstrak data model struct menjadi InsertData format untuk QueryBuilder func extractInsertData(obj interface{}, conflictCol string) (query.InsertData, []string) { val := reflect.ValueOf(obj) if val.Kind() == reflect.Ptr { val = val.Elem() } typ := val.Type() var cols []string var vals []interface{} var updateCols []string for i := 0; i < typ.NumField(); i++ { field := typ.Field(i) if field.PkgPath != "" { // Skip properti yang tidak diekspor continue } colName := toSnakeCase(field.Name) if colName == "id" { // ID selalu di-skip pada operasi insert karena dia auto increment continue } fieldVal := val.Field(i).Interface() cols = append(cols, colName) if b, ok := fieldVal.(json.RawMessage); ok { if len(b) == 0 || string(b) == "null" { vals = append(vals, nil) } else { vals = append(vals, string(b)) } } else { vals = append(vals, fieldVal) } // Cek apa saja kolom yang valid di-update saat konflik (mengecualikan relasi/kode referensi) if colName != conflictCol && colName != "created_at" && colName != "product_kfa_code" { updateCols = append(updateCols, colName) } } return query.InsertData{ Columns: cols, Values: vals, }, updateCols } // Helper: Ubah PascalCase field Go ke mode snake_case untuk tabel database func toSnakeCase(s string) string { if s == "ID" { return "id" } var result strings.Builder var prevUpper bool for i, r := range s { if unicode.IsUpper(r) { if i > 0 && !prevUpper { result.WriteByte('_') } result.WriteRune(unicode.ToLower(r)) prevUpper = true } else { result.WriteRune(r) prevUpper = false } } return result.String() }