update ignore
This commit is contained in:
No files matched your search
@@ -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"`
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
1
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user