update ignore

This commit is contained in:
meninjar
2026-08-05 06:47:59 +00:00
parent 0add33f888
commit d0f0ee168b
273 changed files with 1234069 additions and 0 deletions

No files matched your search

+97
View File
@@ -0,0 +1,97 @@
package kfa
type KfaListResponse struct {
Status string `json:"status"`
Message string `json:"message"`
Data struct {
Items struct {
Data []KfaListItem `json:"data"`
} `json:"items"`
Page int `json:"page"`
Size int `json:"size"`
Total int `json:"total"`
} `json:"data"`
}
type KfaListItem struct {
KfaCode string `json:"kfa_code"`
}
type KfaDetailResponse struct {
Status string `json:"status"`
Message string `json:"message"`
Data struct {
Result KfaDetailResult `json:"result"`
} `json:"data"`
}
type KfaDetailResult struct {
Active bool `json:"active"`
ActiveIngredients []KfaDetailActiveIngredient `json:"active_ingredients"`
AtcDdd interface{} `json:"atc_ddd"`
AtcL1 interface{} `json:"atc_l1"`
AtcL2 interface{} `json:"atc_l2"`
AtcL3 interface{} `json:"atc_l3"`
AtcL4 interface{} `json:"atc_l4"`
AtcL5 interface{} `json:"atc_l5"`
ControlledDrug interface{} `json:"controlled_drug"`
Description *string `json:"description"`
DosageForm interface{} `json:"dosage_form"`
DosageUsage interface{} `json:"dosage_usage"`
DosePerUnit *float64 `json:"dose_per_unit"`
FarmalkesHscode *string `json:"farmalkes_hscode"`
FarmalkesType interface{} `json:"farmalkes_type"`
FixPrice *float64 `json:"fix_price"`
Generik *bool `json:"generik"`
HetPrice *float64 `json:"het_price"`
IdentifierIds interface{} `json:"identifier_ids"`
Image *string `json:"image"`
Indication *string `json:"indication"`
KfaCode string `json:"kfa_code"`
KlasifikasiIzin *string `json:"klasifikasi_izin"`
KodeLkpp *string `json:"kode_lkpp"`
Manufacturer *string `json:"manufacturer"`
NamaDagang *string `json:"nama_dagang"`
Name string `json:"name"`
NetWeight *float64 `json:"net_weight"`
NetWeightUomName *string `json:"net_weight_uom_name"`
Nie *string `json:"nie"`
PackagingIds []KfaDetailPackaging `json:"packaging_ids"`
ProductTemplate interface{} `json:"product_template"`
ProduksiBuatan *string `json:"produksi_buatan"`
Registrar *string `json:"registrar"`
Replacement interface{} `json:"replacement"`
RutePemberian interface{} `json:"rute_pemberian"`
Rxterm *int `json:"rxterm"`
ScoreBmp *float64 `json:"score_bmp"`
ScoreTkdn *float64 `json:"score_tkdn"`
ScoreTkdnBmp *float64 `json:"score_tkdn_bmp"`
SideEffect *string `json:"side_effect"`
State *string `json:"state"`
Tags interface{} `json:"tags"`
TayangLkpp *bool `json:"tayang_lkpp"`
Ucum interface{} `json:"ucum"`
Uom interface{} `json:"uom"`
UpdatedAt *string `json:"updated_at"`
Volume *float64 `json:"volume"`
VolumeUomName *string `json:"volume_uom_name"`
Warning *string `json:"warning"`
Fornas interface{} `json:"fornas"`
}
type KfaDetailActiveIngredient struct {
Active bool `json:"active"`
KekuatanZatAktif string `json:"kekuatan_zat_aktif"`
KfaCode string `json:"kfa_code"`
State string `json:"state"`
UpdatedAt *string `json:"updated_at"`
ZatAktif string `json:"zat_aktif"`
}
type KfaDetailPackaging struct {
KfaCode string `json:"kfa_code"`
Name string `json:"name"`
PackPrice *float64 `json:"pack_price"`
Qty int `json:"qty"`
UomId string `json:"uom_id"`
}
+98
View File
@@ -0,0 +1,98 @@
package kfa
import (
"encoding/json"
"time"
)
type KfaProduct struct {
ID int64 `gorm:"primaryKey;autoIncrement"`
KfaCode string `gorm:"type:varchar(20);uniqueIndex;not null"`
Name string `gorm:"type:varchar(150);not null"`
DisplayName *string `gorm:"type:text"`
TradeName *string `gorm:"type:varchar(150)"`
State *string `gorm:"type:varchar(20)"`
IsGeneric *bool `gorm:"default:false"`
DosageForm json.RawMessage `gorm:"type:jsonb"`
FarmalkesType json.RawMessage `gorm:"type:jsonb"`
Route json.RawMessage `gorm:"type:jsonb"`
ControlledDrug json.RawMessage `gorm:"type:jsonb"`
Uom json.RawMessage `gorm:"type:jsonb"`
FixPrice *float64 `gorm:"type:numeric(12,2)"`
HetPrice *float64 `gorm:"type:numeric(12,2)"`
DosePerUnit *float64 `gorm:"type:numeric(10,2)"`
NetWeight *float64 `gorm:"type:numeric(10,2)"`
NetWeightUom *string `gorm:"type:varchar(20)"`
Volume *float64 `gorm:"type:numeric(10,2)"`
VolumeUom *string `gorm:"type:varchar(20)"`
ScoreTkdn *float64 `gorm:"type:numeric(5,2)"`
ScoreTkdnBmp *float64 `gorm:"type:numeric(5,2)"`
ScoreBmp *float64 `gorm:"type:numeric(5,2)"`
Rxterm *int `gorm:"type:int4"`
Manufacturer *string `gorm:"type:varchar(150)"`
Registrar *string `gorm:"type:varchar(150)"`
Nie *string `gorm:"type:varchar(100)"`
Hscode *string `gorm:"type:varchar(100)"`
LkppCode *string `gorm:"type:varchar(100)"`
IsLkppActive *bool `gorm:"default:true"`
Description *string `gorm:"type:text"`
Indication *string `gorm:"type:text"`
SideEffect *string `gorm:"type:text"`
Warning *string `gorm:"type:text"`
Image *string `gorm:"type:varchar(100)"`
DrugClassification *string `gorm:"type:varchar(100)"`
ManufacturingOrigin *string `gorm:"type:varchar(100)"`
AtcInfo json.RawMessage `gorm:"type:jsonb"`
Fornas json.RawMessage `gorm:"type:jsonb"`
DosageUsage json.RawMessage `gorm:"type:jsonb"`
Tags json.RawMessage `gorm:"type:jsonb"`
IdentifierIds json.RawMessage `gorm:"type:jsonb"`
ProductTemplate json.RawMessage `gorm:"type:jsonb"`
Replacement json.RawMessage `gorm:"type:jsonb"`
Ucum json.RawMessage `gorm:"type:jsonb"`
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
Active *bool `gorm:"default:true"`
}
func (KfaProduct) TableName() string {
return "master.kfa_products"
}
type KfaProductActiveIngredient struct {
ID int64 `gorm:"primaryKey;autoIncrement"`
ProductKfaCode string `gorm:"type:varchar(20);index"`
ProductDisplay string `gorm:"type:text"`
SubstanceCode string `gorm:"type:varchar(20)"`
SubstanceName string `gorm:"type:varchar(150)"`
StrengthValue *float64 `gorm:"type:numeric(10,2)"`
StrengthUnit string `gorm:"type:varchar(20)"`
StrengthText string `gorm:"type:varchar(100)"`
State string `gorm:"type:varchar(20)"`
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
Active *bool `gorm:"default:true"`
}
func (KfaProductActiveIngredient) TableName() string {
return "master.kfa_product_active_ingredients"
}
type KfaProductPackaging struct {
ID int64 `gorm:"primaryKey;autoIncrement"`
ProductKfaCode string `gorm:"type:varchar(20);index"`
ProductDisplay string `gorm:"type:text"`
Code string `gorm:"type:varchar(20)"`
Name string `gorm:"type:varchar(150)"`
Quantity int `gorm:"type:int4"`
Unit string `gorm:"type:varchar(50)"`
Price *float64 `gorm:"type:numeric(12,2)"`
State string `gorm:"type:varchar(20)"`
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
Active *bool `gorm:"default:true"`
}
func (KfaProductPackaging) TableName() string {
return "master.kfa_product_packagings"
}
+1
View File
@@ -0,0 +1 @@
1
+168
View File
@@ -0,0 +1,168 @@
package kfa
import (
"encoding/json"
"strconv"
"strings"
"time"
)
func MapKfaDetailToEntity(detail KfaDetailResult) (*KfaProduct, []KfaProductActiveIngredient, []KfaProductPackaging) {
now := time.Now()
var displayName *string
// Attempt to extract display_name safely if product template exists
templateB := toJsonb(detail.ProductTemplate)
if templateB != nil {
var tpl struct {
DisplayName string `json:"display_name"`
}
if err := json.Unmarshal(templateB, &tpl); err == nil && tpl.DisplayName != "" {
displayName = &tpl.DisplayName
}
}
if displayName == nil {
displayName = &detail.Name
}
product := &KfaProduct{
KfaCode: detail.KfaCode,
Name: detail.Name,
DisplayName: displayName,
TradeName: detail.NamaDagang,
State: detail.State,
IsGeneric: detail.Generik,
DosageForm: toJsonb(detail.DosageForm),
FarmalkesType: toJsonb(detail.FarmalkesType),
Route: toJsonb(detail.RutePemberian),
ControlledDrug: toJsonb(detail.ControlledDrug),
Uom: toJsonb(detail.Uom),
FixPrice: detail.FixPrice,
HetPrice: detail.HetPrice,
DosePerUnit: detail.DosePerUnit,
NetWeight: detail.NetWeight,
NetWeightUom: detail.NetWeightUomName,
Volume: detail.Volume,
VolumeUom: detail.VolumeUomName,
ScoreTkdn: detail.ScoreTkdn,
ScoreTkdnBmp: detail.ScoreTkdnBmp,
ScoreBmp: detail.ScoreBmp,
Rxterm: detail.Rxterm,
Manufacturer: detail.Manufacturer,
Registrar: detail.Registrar,
Nie: detail.Nie,
Hscode: detail.FarmalkesHscode,
LkppCode: detail.KodeLkpp,
IsLkppActive: detail.TayangLkpp,
Description: detail.Description,
Indication: detail.Indication,
SideEffect: detail.SideEffect,
Warning: detail.Warning,
Image: detail.Image,
DrugClassification: detail.KlasifikasiIzin,
ManufacturingOrigin: detail.ProduksiBuatan,
AtcInfo: buildAtcInfo(detail),
Fornas: toJsonb(detail.Fornas),
DosageUsage: toJsonb(detail.DosageUsage),
Tags: toJsonb(detail.Tags),
IdentifierIds: toJsonb(detail.IdentifierIds),
ProductTemplate: templateB,
Replacement: toJsonb(detail.Replacement),
Ucum: toJsonb(detail.Ucum),
CreatedAt: now,
UpdatedAt: parseTime(detail.UpdatedAt, now),
Active: &detail.Active,
}
var ingredients []KfaProductActiveIngredient
for _, ing := range detail.ActiveIngredients {
val, unit := parseStrength(ing.KekuatanZatAktif)
ingredients = append(ingredients, KfaProductActiveIngredient{
ProductKfaCode: product.KfaCode,
ProductDisplay: product.Name,
SubstanceCode: ing.KfaCode,
SubstanceName: ing.ZatAktif,
StrengthValue: val,
StrengthUnit: unit,
StrengthText: ing.KekuatanZatAktif,
State: ing.State,
CreatedAt: now,
UpdatedAt: parseTime(ing.UpdatedAt, now),
Active: &ing.Active,
})
}
var packagings []KfaProductPackaging
for _, pkg := range detail.PackagingIds {
active := true
packagings = append(packagings, KfaProductPackaging{
ProductKfaCode: product.KfaCode,
ProductDisplay: product.Name,
Code: pkg.KfaCode,
Name: pkg.Name,
Quantity: pkg.Qty,
Unit: pkg.UomId,
Price: pkg.PackPrice,
State: "valid",
CreatedAt: now,
UpdatedAt: now,
Active: &active,
})
}
return product, ingredients, packagings
}
func toJsonb(v interface{}) json.RawMessage {
if v == nil {
return nil
}
b, err := json.Marshal(v)
if err != nil || string(b) == "null" {
return nil
}
return b
}
func buildAtcInfo(d KfaDetailResult) json.RawMessage {
m := map[string]interface{}{
"atc_ddd": d.AtcDdd,
"atc_l1": d.AtcL1,
"atc_l2": d.AtcL2,
"atc_l3": d.AtcL3,
"atc_l4": d.AtcL4,
"atc_l5": d.AtcL5,
}
return toJsonb(m)
}
func parseStrength(text string) (*float64, string) {
text = strings.TrimSpace(text)
if text == "" {
return nil, ""
}
parts := strings.Split(text, " ")
if len(parts) == 0 {
return nil, ""
}
val, err := strconv.ParseFloat(parts[0], 64)
if err != nil {
return nil, text
}
var unit string
if len(parts) > 1 {
unit = strings.Join(parts[1:], " ")
}
return &val, unit
}
func parseTime(t *string, defaultTime time.Time) time.Time {
if t == nil || *t == "" {
return defaultTime
}
parsed, err := time.Parse("2006-01-02 15:04:05", *t)
if err != nil {
return defaultTime
}
return parsed
}
+218
View File
@@ -0,0 +1,218 @@
package kfa
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
"service/pkg/logger"
)
type TokenManager interface {
GetAccessToken() string
ForceRefreshAndGetToken(ctx context.Context) (string, error)
}
type Config struct {
InternalBaseURL string
PageSize int
}
type Worker struct {
cfg Config
repo CommandRepository
tokenManager TokenManager
}
func NewWorker(cfg Config, repo CommandRepository, tokenManager TokenManager) *Worker {
if cfg.PageSize == 0 {
cfg.PageSize = 100
}
return &Worker{
cfg: cfg,
repo: repo,
tokenManager: tokenManager,
}
}
func (w *Worker) readTracker() int {
b, err := os.ReadFile("internal/master/kfa/kfa_tracker.txt")
if err != nil {
return 1
}
p, err := strconv.Atoi(strings.TrimSpace(string(b)))
if err != nil || p < 1 {
return 1
}
return p
}
func (w *Worker) writeTracker(page int) {
os.WriteFile("internal/master/kfa/kfa_tracker.txt", []byte(strconv.Itoa(page)), 0644)
}
func (w *Worker) Run(ctx context.Context) {
page := w.readTracker()
for {
select {
case <-ctx.Done():
logger.Default().Info("KFA Master Puller stopped")
return
default:
err := w.processPage(ctx, page)
if err != nil {
logger.Default().Error("KFA Master Puller error processing page", logger.ErrorField(err), logger.Int("page", page))
time.Sleep(30 * time.Second)
continue
}
page++
w.writeTracker(page)
time.Sleep(5 * time.Second) // Jedah halus antar halaman
}
}
}
func (w *Worker) processPage(ctx context.Context, page int) error {
listURL := fmt.Sprintf("%s/satusehat/reference/kfa/products?page=%d&size=%d&product_type=farmasi", strings.TrimRight(w.cfg.InternalBaseURL, "/"), page, w.cfg.PageSize)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, listURL, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+w.tokenManager.GetAccessToken())
req.Header.Set("Accept", "application/json")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return err
}
if resp.StatusCode == http.StatusUnauthorized {
resp.Body.Close()
logger.Default().Warn("KFA Master Puller list API 401, refreshing token...", logger.Int("page", page))
newToken, err := w.tokenManager.ForceRefreshAndGetToken(ctx)
if err != nil {
return err
}
req, _ = http.NewRequestWithContext(ctx, http.MethodGet, listURL, nil)
req.Header.Set("Authorization", "Bearer "+newToken)
req.Header.Set("Accept", "application/json")
resp, err = client.Do(req)
if err != nil {
return err
}
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusTooManyRequests {
logger.Default().Warn("[KFA WORKER] ⏸️ Rate limit (429) pada List API, jeda 30s & ulangi halaman", logger.Int("page", page))
return fmt.Errorf("rate limited on list API") // Akan memicu waktu tidur 30 detik di method Run() dan mencoba halaman yang sama
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("list API returned status: %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
var listRes KfaListResponse
if err := json.Unmarshal(body, &listRes); err != nil {
return err
}
if len(listRes.Data.Items.Data) == 0 {
logger.Default().Info("No more KFA products found, resetting to page 1 and sleeping", logger.Int("page", page))
w.writeTracker(1)
time.Sleep(6 * time.Hour)
return fmt.Errorf("empty page, reset triggered")
}
logger.Default().Info("Pulling KFA items", logger.Int("page", page), logger.Int("items", len(listRes.Data.Items.Data)))
for _, item := range listRes.Data.Items.Data {
for {
rateLimited, err := w.fetchAndSaveDetail(ctx, item.KfaCode)
if rateLimited {
logger.Default().Warn("[KFA WORKER] ⏸️ Rate limit (429) dari Satu Sehat - jeda 60 detik dan retry dokumen yang sama", logger.String("kfa_code", item.KfaCode))
time.Sleep(60 * time.Second)
continue // Ulangi request untuk item ini (Tidak melewatkan ID)
}
if err != nil {
logger.Default().Error("Failed to fetch/save KFA detail", logger.String("kfa_code", item.KfaCode), logger.ErrorField(err))
}
break // Lanjut ke item/ID berikutnya bila sukses atau gagal karena alasan non-ratelimit
}
time.Sleep(500 * time.Millisecond) // Pencegahan Rate Limit Part 3
}
return nil
}
func (w *Worker) fetchAndSaveDetail(ctx context.Context, kfaCode string) (bool, error) {
detailURL := fmt.Sprintf("%s/satusehat/reference/kfa/products/%s", strings.TrimRight(w.cfg.InternalBaseURL, "/"), kfaCode)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, detailURL, nil)
if err != nil {
return false, err
}
req.Header.Set("Authorization", "Bearer "+w.tokenManager.GetAccessToken())
req.Header.Set("Accept", "application/json")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return false, err
}
if resp.StatusCode == http.StatusUnauthorized {
resp.Body.Close()
newToken, err := w.tokenManager.ForceRefreshAndGetToken(ctx)
if err != nil {
return false, err
}
req, _ = http.NewRequestWithContext(ctx, http.MethodGet, detailURL, nil)
req.Header.Set("Authorization", "Bearer "+newToken)
req.Header.Set("Accept", "application/json")
resp, err = client.Do(req)
if err != nil {
return false, err
}
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusTooManyRequests {
return true, fmt.Errorf("rate limited (429)")
}
if resp.StatusCode != http.StatusOK {
return false, fmt.Errorf("detail API returned status: %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return false, err
}
var detailRes KfaDetailResponse
if err := json.Unmarshal(body, &detailRes); err != nil {
return false, err
}
product, ingredients, packagings := MapKfaDetailToEntity(detailRes.Data.Result)
return false, w.repo.UpsertProductBundle(ctx, product, ingredients, packagings)
}
+217
View File
@@ -0,0 +1,217 @@
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()
}