package routes import ( "bssapp-backend/auth" "bssapp-backend/queries" "bssapp-backend/utils" "database/sql" "encoding/json" "fmt" "github.com/lib/pq" "net/http" "sort" "strconv" "strings" "time" ) // Step-1/2 scope (distinct+cascade) comes from the PostgreSQL parameter cache. // For now we implement: // - Postgres tables (bootstrap) // - List/Save rules (bulk) // - Options endpoint for cascade (mk_urunpricingprmtr) type PricingRuleBulkSavePayload struct { Items []queries.PricingRuleSaveItem `json:"items"` } type PricingRuleImportItem struct { AskiliYan string `json:"askili_yan"` Kategori string `json:"kategori"` UrunIlkGrubu string `json:"urun_ilk_grubu"` UrunAnaGrubu string `json:"urun_ana_grubu"` UrunAltGrubu string `json:"urun_alt_grubu"` Icerik string `json:"icerik"` Marka string `json:"marka"` BrandCode string `json:"brand_code"` BrandGroupSec string `json:"brand_group"` StrategyCode string `json:"strategy_code"` AnchorMode string `json:"anchor_mode"` CalcEnabled bool `json:"calc_enabled"` PublishPostgres bool `json:"publish_postgres"` PublishNebim bool `json:"publish_nebim"` IsActive bool `json:"is_active"` TryBase float64 `json:"try_base"` Try1 float64 `json:"try1"` Try2 float64 `json:"try2"` Try3 float64 `json:"try3"` Try4 float64 `json:"try4"` Try5 float64 `json:"try5"` Try6 float64 `json:"try6"` TryWholesaleStep float64 `json:"try_wholesale_step"` TryRetailStep float64 `json:"try_retail_step"` TryRetailMode string `json:"try_retail_mode"` UsdBase float64 `json:"usd_base"` Usd1 float64 `json:"usd1"` Usd2 float64 `json:"usd2"` Usd3 float64 `json:"usd3"` Usd4 float64 `json:"usd4"` Usd5 float64 `json:"usd5"` Usd6 float64 `json:"usd6"` UsdWholesaleStep float64 `json:"usd_wholesale_step"` UsdRetailStep float64 `json:"usd_retail_step"` UsdRetailMode string `json:"usd_retail_mode"` EurBase float64 `json:"eur_base"` Eur1 float64 `json:"eur1"` Eur2 float64 `json:"eur2"` Eur3 float64 `json:"eur3"` Eur4 float64 `json:"eur4"` Eur5 float64 `json:"eur5"` Eur6 float64 `json:"eur6"` EurWholesaleStep float64 `json:"eur_wholesale_step"` EurRetailStep float64 `json:"eur_retail_step"` EurRetailMode string `json:"eur_retail_mode"` } type PricingRuleImportPayload struct { Items []PricingRuleImportItem `json:"items"` } type PricingRuleImportResult struct { Success bool `json:"success"` Processed int `json:"processed"` Matched int `json:"matched"` Skipped int `json:"skipped"` Updated int `json:"updated"` ActivatedScopeCount int `json:"activated_scope_count"` ErrorCount int `json:"error_count"` } func normalizePricingStrategyCode(v string) string { v = strings.ToUpper(strings.TrimSpace(v)) if v == "" { return "CORE" } return v } func normalizePricingAnchorMode(v string) string { v = strings.ToUpper(strings.TrimSpace(v)) if v == "" { return "USD" } return v } func isValidPricingStrategyCode(v string) bool { if strings.TrimSpace(v) == "" { return true } switch normalizePricingStrategyCode(v) { case "CORE", "PREMIUM", "SARTORIAL": return true default: return false } } func isValidPricingAnchorMode(v string) bool { switch normalizePricingAnchorMode(v) { case "TRY", "USD": return true default: return false } } func isValidPricingRetailMode(v string) bool { switch queries.NormalizeRetailModeForRoute(v) { case "STEP", "END_99", "END_49", "BAND_99", "BAND_49": return true default: return false } } func GetPricingRulesHandler(pg *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json; charset=utf-8") traceID := utils.TraceIDFromRequest(r) ctx := utils.ContextWithTraceID(r.Context(), traceID) rows, err := queries.ListPricingRules(ctx, pg) if err != nil { http.Error(w, "pricing rules list error", http.StatusInternalServerError) return } _ = json.NewEncoder(w).Encode(rows) } } // Very small “bulk upsert” for step-1/2: we only need to persist the multipliers+roundings for now. // Rules are identified by UUID; new rows can be created via empty id (server generates). func SavePricingRulesBulkHandler(pg *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json; charset=utf-8") var payload PricingRuleBulkSavePayload if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { http.Error(w, "invalid payload", http.StatusBadRequest) return } started := time.Now() traceID := utils.TraceIDFromRequest(r) w.Header().Set("X-Trace-ID", traceID) ctx := utils.ContextWithTraceID(r.Context(), traceID) logger := utils.SlogFromContext(ctx).With("handler", "pricing-rules.bulk-save") claims, _ := auth.GetClaimsFromContext(ctx) if claims != nil { logger = logger.With("user", claims.Username, "user_id", claims.ID) } existingIDCount := 0 newIDCount := 0 for _, it := range payload.Items { if strings.TrimSpace(it.ID) != "" { existingIDCount++ } else { newIDCount++ } } logger.Info("bulk-save:start", "items", len(payload.Items), "existing_id", existingIDCount, "new_id", newIDCount, ) tx, err := pg.BeginTx(ctx, nil) if err != nil { logger.Error("bulk-save:tx-begin:error", "err", err) http.Error(w, "pg transaction start error", http.StatusInternalServerError) return } defer tx.Rollback() // Serialize writes touching mk_urunpricingprmtr/mk_pricing_rule/mk_pricex/mk_priceroll // to avoid deadlocks with pricing-parameter sync and concurrent bulk-saves. lockWaitStarted := time.Now() if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(1001, 1)`); err != nil { logger.Error("bulk-save:advisory-lock:error", "err", err) http.Error(w, "pg advisory lock error", http.StatusInternalServerError) return } logger.Info("bulk-save:advisory-lock:acquired", "wait_ms", time.Since(lockWaitStarted).Milliseconds()) logPgErr := func(msg string, err error, it queries.PricingRuleSaveItem) { fields := []any{ "pricing_parameter_id", it.PricingParameterID, "id", strings.TrimSpace(it.ID), "err", err, } if pe, ok := err.(*pq.Error); ok && pe != nil { fields = append(fields, "sqlstate", string(pe.Code), "constraint", pe.Constraint, "table", pe.Table, "column", pe.Column, "detail", pe.Detail, "where", pe.Where, ) } logger.Error(msg, fields...) } updated := 0 for _, it := range payload.Items { // Zero means that no rounding rule has been configured yet. if it.TryWholesaleStep < 0 || it.TryRetailStep < 0 || it.UsdWholesaleStep < 0 || it.UsdRetailStep < 0 || it.EurWholesaleStep < 0 || it.EurRetailStep < 0 { logger.Warn("bulk-save:invalid-rounding-step", "pricing_parameter_id", it.PricingParameterID, "id", strings.TrimSpace(it.ID), ) http.Error(w, "invalid rounding step", http.StatusBadRequest) return } if !isValidPricingStrategyCode(it.StrategyCode) { logger.Warn("bulk-save:invalid-strategy-code", "pricing_parameter_id", it.PricingParameterID, "id", strings.TrimSpace(it.ID), "strategy_code", it.StrategyCode, ) http.Error(w, "invalid strategy_code", http.StatusBadRequest) return } if !isValidPricingAnchorMode(it.AnchorMode) { logger.Warn("bulk-save:invalid-anchor-mode", "pricing_parameter_id", it.PricingParameterID, "id", strings.TrimSpace(it.ID), "anchor_mode", it.AnchorMode, ) http.Error(w, "invalid anchor_mode", http.StatusBadRequest) return } if !isValidPricingRetailMode(it.TryRetailMode) || !isValidPricingRetailMode(it.UsdRetailMode) || !isValidPricingRetailMode(it.EurRetailMode) { logger.Warn("bulk-save:invalid-retail-mode", "pricing_parameter_id", it.PricingParameterID, "id", strings.TrimSpace(it.ID), "try_retail_mode", it.TryRetailMode, "usd_retail_mode", it.UsdRetailMode, "eur_retail_mode", it.EurRetailMode, ) http.Error(w, "invalid retail_mode", http.StatusBadRequest) return } } dbStarted := time.Now() updated, err = queries.BulkSavePricingRulesFast(ctx, tx, payload.Items) if err != nil { // best-effort: log first item context if len(payload.Items) > 0 { logPgErr("bulk-save:bulk-fast:error", err, payload.Items[0]) } else { logger.Error("bulk-save:bulk-fast:error", "err", err) } http.Error(w, "pricing rule save error: "+err.Error(), http.StatusInternalServerError) return } logger.Info("bulk-save:db:done", "updated", updated, "duration_ms", time.Since(dbStarted).Milliseconds()) if err := tx.Commit(); err != nil { logger.Error("bulk-save:commit:error", "err", err) http.Error(w, "pg transaction commit error", http.StatusInternalServerError) return } logger.Info("bulk-save:done", "updated", updated, "duration_ms", time.Since(started).Milliseconds()) _ = json.NewEncoder(w).Encode(map[string]any{"success": true, "updated": updated}) } } func ImportPricingRulesHandler(pg *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json; charset=utf-8") var payload PricingRuleImportPayload if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { http.Error(w, "invalid payload", http.StatusBadRequest) return } if len(payload.Items) == 0 { _ = json.NewEncoder(w).Encode(PricingRuleImportResult{Success: true}) return } traceID := utils.TraceIDFromRequest(r) ctx := utils.ContextWithTraceID(r.Context(), traceID) tx, err := pg.BeginTx(ctx, nil) if err != nil { http.Error(w, "pg transaction start error", http.StatusInternalServerError) return } defer tx.Rollback() // Same global lock as bulk-save: prevents deadlocks with concurrent updates/sync. if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(1001, 1)`); err != nil { http.Error(w, "pg advisory lock error", http.StatusInternalServerError) return } updated := 0 matched := 0 skipped := 0 for _, raw := range payload.Items { if raw.TryWholesaleStep < 0 || raw.TryRetailStep < 0 || raw.UsdWholesaleStep < 0 || raw.UsdRetailStep < 0 || raw.EurWholesaleStep < 0 || raw.EurRetailStep < 0 { http.Error(w, "invalid rounding step", http.StatusBadRequest) return } if !isValidPricingStrategyCode(raw.StrategyCode) { http.Error(w, "invalid strategy_code", http.StatusBadRequest) return } if !isValidPricingAnchorMode(raw.AnchorMode) { http.Error(w, "invalid anchor_mode", http.StatusBadRequest) return } if !isValidPricingRetailMode(raw.TryRetailMode) || !isValidPricingRetailMode(raw.UsdRetailMode) || !isValidPricingRetailMode(raw.EurRetailMode) { http.Error(w, "invalid retail_mode", http.StatusBadRequest) return } pricingParameterID, err := queries.FindActivePricingParameterByScope(ctx, tx, queries.PricingParameterRowForImport( raw.AskiliYan, raw.Kategori, raw.UrunIlkGrubu, raw.UrunAnaGrubu, raw.UrunAltGrubu, raw.Icerik, raw.Marka, raw.BrandCode, raw.BrandGroupSec, )) if err == sql.ErrNoRows { skipped++ continue } if err != nil { http.Error(w, "pricing parameter resolve error", http.StatusInternalServerError) return } matched++ _, err = queries.UpsertPricingRule(ctx, tx, queries.PricingRuleSaveItem{ PricingParameterID: pricingParameterID, StrategyCode: normalizePricingStrategyCode(raw.StrategyCode), AnchorMode: normalizePricingAnchorMode(raw.AnchorMode), CalcEnabled: raw.CalcEnabled, PublishPostgres: raw.PublishPostgres, PublishNebim: raw.PublishNebim, IsActive: raw.IsActive, TryBase: raw.TryBase, Try1: raw.Try1, Try2: raw.Try2, Try3: raw.Try3, Try4: raw.Try4, Try5: raw.Try5, Try6: raw.Try6, TryWholesaleStep: raw.TryWholesaleStep, TryRetailStep: raw.TryRetailStep, TryRetailMode: queries.NormalizeRetailModeForRoute(raw.TryRetailMode), UsdBase: raw.UsdBase, Usd1: raw.Usd1, Usd2: raw.Usd2, Usd3: raw.Usd3, Usd4: raw.Usd4, Usd5: raw.Usd5, Usd6: raw.Usd6, UsdWholesaleStep: raw.UsdWholesaleStep, UsdRetailStep: raw.UsdRetailStep, UsdRetailMode: queries.NormalizeRetailModeForRoute(raw.UsdRetailMode), EurBase: raw.EurBase, Eur1: raw.Eur1, Eur2: raw.Eur2, Eur3: raw.Eur3, Eur4: raw.Eur4, Eur5: raw.Eur5, Eur6: raw.Eur6, EurWholesaleStep: raw.EurWholesaleStep, EurRetailStep: raw.EurRetailStep, EurRetailMode: queries.NormalizeRetailModeForRoute(raw.EurRetailMode), }) if err != nil { http.Error(w, "pricing rule import error", http.StatusInternalServerError) return } updated++ } if err := tx.Commit(); err != nil { http.Error(w, "pg transaction commit error", http.StatusInternalServerError) return } _ = json.NewEncoder(w).Encode(PricingRuleImportResult{ Success: true, Processed: len(payload.Items), Matched: matched, Skipped: skipped, Updated: updated, ActivatedScopeCount: 0, ErrorCount: 0, }) } } func GetPricingRuleOptionsHandler(pg *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json; charset=utf-8") field := strings.TrimSpace(r.URL.Query().Get("field")) if field == "" { http.Error(w, "missing field", http.StatusBadRequest) return } limit := 500 if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" { if n, err := strconv.Atoi(raw); err == nil && n > 0 && n <= 5000 { limit = n } } f := pricingRuleFiltersFromRequest(r) traceID := utils.TraceIDFromRequest(r) ctx := utils.ContextWithTraceID(r.Context(), traceID) opts, err := queries.ListPricingParameterDistinctOptions(ctx, pg, field, f, limit) if err != nil { http.Error(w, "options lookup error", http.StatusInternalServerError) return } _ = json.NewEncoder(w).Encode(map[string]any{ "field": field, "options": opts, }) } } func GetPricingParameterRulesHandler(pg *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json; charset=utf-8") traceID := utils.TraceIDFromRequest(r) ctx := utils.ContextWithTraceID(r.Context(), traceID) rows, err := queries.ListPricingParameterRules(ctx, pg, pricingRuleFiltersFromRequest(r)) if err != nil { http.Error(w, "pricing parameter rules list error", http.StatusInternalServerError) return } _ = json.NewEncoder(w).Encode(rows) } } func ExportPricingRulesHandler(pg *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { traceID := utils.TraceIDFromRequest(r) ctx := utils.ContextWithTraceID(r.Context(), traceID) rows, err := queries.ListPricingParameterRules(ctx, pg, pricingRuleFiltersFromRequest(r)) if err != nil { http.Error(w, "pricing parameter rules export error", http.StatusInternalServerError) return } rows = filterPricingRuleExportRows(rows, r) sortPricingRuleExportRows(rows, strings.TrimSpace(r.URL.Query().Get("sort_by")), strings.TrimSpace(r.URL.Query().Get("desc")) != "0") filename := fmt.Sprintf("pricing_rules_all_%s.csv", time.Now().Format("2006-01-02")) w.Header().Set("Content-Type", "text/csv; charset=utf-8") w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename)) _, _ = w.Write([]byte("\uFEFF")) _, _ = w.Write([]byte(buildPricingRuleCSV(rows))) } } func pricingRuleFiltersFromRequest(r *http.Request) queries.PricingRuleOptionFilters { return queries.PricingRuleOptionFilters{ AskiliYan: splitCSV(r.URL.Query().Get("askili_yan")), Kategori: splitCSV(r.URL.Query().Get("kategori")), UrunIlkGrubu: splitCSV(r.URL.Query().Get("urun_ilk_grubu")), UrunAnaGrubu: splitCSV(r.URL.Query().Get("urun_ana_grubu")), UrunAltGrubu: splitCSV(r.URL.Query().Get("urun_alt_grubu")), Icerik: splitCSV(r.URL.Query().Get("icerik")), Marka: splitCSV(r.URL.Query().Get("marka")), BrandCode: splitCSV(r.URL.Query().Get("brand_code")), BrandGroupSec: splitCSV(r.URL.Query().Get("brand_group")), } } func filterPricingRuleExportRows(rows []queries.PricingParameterRuleRow, r *http.Request) []queries.PricingParameterRuleRow { rangeFilter := func(prefix string) (*float64, *float64) { parse := func(raw string) *float64 { raw = strings.TrimSpace(raw) if raw == "" { return nil } v, err := strconv.ParseFloat(strings.ReplaceAll(raw, ",", "."), 64) if err != nil { return nil } return &v } return parse(r.URL.Query().Get(prefix + "_min")), parse(r.URL.Query().Get(prefix + "_max")) } fields := []string{ "try_base", "try1", "try2", "try3", "try4", "try5", "try6", "try_wholesale_step", "try_retail_step", "usd_base", "usd1", "usd2", "usd3", "usd4", "usd5", "usd6", "usd_wholesale_step", "usd_retail_step", "eur_base", "eur1", "eur2", "eur3", "eur4", "eur5", "eur6", "eur_wholesale_step", "eur_retail_step", } minMap := map[string]*float64{} maxMap := map[string]*float64{} for _, field := range fields { minMap[field], maxMap[field] = rangeFilter(field) } out := make([]queries.PricingParameterRuleRow, 0, len(rows)) for _, row := range rows { ok := true for _, field := range fields { value := pricingRuleNumericValue(row, field) if minMap[field] != nil && value < *minMap[field] { ok = false break } if maxMap[field] != nil && value > *maxMap[field] { ok = false break } } if ok { out = append(out, row) } } return out } func pricingRuleNumericValue(row queries.PricingParameterRuleRow, field string) float64 { if row.Rule == nil { return 0 } switch field { case "try_base": return row.Rule.TryBase case "try1": return row.Rule.Try1 case "try2": return row.Rule.Try2 case "try3": return row.Rule.Try3 case "try4": return row.Rule.Try4 case "try5": return row.Rule.Try5 case "try6": return row.Rule.Try6 case "try_wholesale_step": return row.Rule.TryWholesaleStep case "try_retail_step": return row.Rule.TryRetailStep case "usd_base": return row.Rule.UsdBase case "usd1": return row.Rule.Usd1 case "usd2": return row.Rule.Usd2 case "usd3": return row.Rule.Usd3 case "usd4": return row.Rule.Usd4 case "usd5": return row.Rule.Usd5 case "usd6": return row.Rule.Usd6 case "usd_wholesale_step": return row.Rule.UsdWholesaleStep case "usd_retail_step": return row.Rule.UsdRetailStep case "eur_base": return row.Rule.EurBase case "eur1": return row.Rule.Eur1 case "eur2": return row.Rule.Eur2 case "eur3": return row.Rule.Eur3 case "eur4": return row.Rule.Eur4 case "eur5": return row.Rule.Eur5 case "eur6": return row.Rule.Eur6 case "eur_wholesale_step": return row.Rule.EurWholesaleStep case "eur_retail_step": return row.Rule.EurRetailStep default: return 0 } } func sortPricingRuleExportRows(rows []queries.PricingParameterRuleRow, sortBy string, desc bool) { sortBy = strings.TrimSpace(sortBy) if sortBy == "" { return } sort.SliceStable(rows, func(i, j int) bool { li, lj := rows[i], rows[j] switch sortBy { case "has_rule": if desc { return boolRank(li.HasRule) > boolRank(lj.HasRule) } return boolRank(li.HasRule) < boolRank(lj.HasRule) case "is_active": liActive, ljActive := false, false if li.Rule != nil { liActive = li.Rule.IsActive } if lj.Rule != nil { ljActive = lj.Rule.IsActive } if desc { return boolRank(liActive) > boolRank(ljActive) } return boolRank(liActive) < boolRank(ljActive) case "calc_enabled", "publish_postgres", "publish_nebim": liValue, ljValue := false, false if li.Rule != nil { switch sortBy { case "calc_enabled": liValue = li.Rule.CalcEnabled case "publish_postgres": liValue = li.Rule.PublishPostgres case "publish_nebim": liValue = li.Rule.PublishNebim } } if lj.Rule != nil { switch sortBy { case "calc_enabled": ljValue = lj.Rule.CalcEnabled case "publish_postgres": ljValue = lj.Rule.PublishPostgres case "publish_nebim": ljValue = lj.Rule.PublishNebim } } if desc { return boolRank(liValue) > boolRank(ljValue) } return boolRank(liValue) < boolRank(ljValue) case "askili_yan", "kategori", "urun_ilk_grubu", "urun_ana_grubu", "urun_alt_grubu", "icerik", "marka", "brand_code", "brand_group", "anchor_mode", "try_retail_mode", "usd_retail_mode", "eur_retail_mode": vi := pricingRuleStringValue(li, sortBy) vj := pricingRuleStringValue(lj, sortBy) if desc { return strings.Compare(vi, vj) > 0 } return strings.Compare(vi, vj) < 0 default: vi := pricingRuleNumericValue(li, sortBy) vj := pricingRuleNumericValue(lj, sortBy) if desc { return vi > vj } return vi < vj } }) } func boolRank(v bool) int { if v { return 1 } return 0 } func pricingRuleStringValue(row queries.PricingParameterRuleRow, field string) string { switch field { case "askili_yan": return row.AskiliYan case "kategori": return row.Kategori case "urun_ilk_grubu": return row.UrunIlkGrubu case "urun_ana_grubu": return row.UrunAnaGrubu case "urun_alt_grubu": return row.UrunAltGrubu case "icerik": return row.Icerik case "marka": return row.Marka case "brand_code": return row.BrandCode case "brand_group": return row.BrandGroupSec case "anchor_mode": if row.Rule == nil { return "USD" } return row.Rule.AnchorMode case "try_retail_mode": if row.Rule == nil { return "STEP" } return queries.NormalizeRetailModeForRoute(row.Rule.TryRetailMode) case "usd_retail_mode": if row.Rule == nil { return "STEP" } return queries.NormalizeRetailModeForRoute(row.Rule.UsdRetailMode) case "eur_retail_mode": if row.Rule == nil { return "STEP" } return queries.NormalizeRetailModeForRoute(row.Rule.EurRetailMode) default: return "" } } func buildPricingRuleCSV(rows []queries.PricingParameterRuleRow) string { headers := []string{ "DURUM", "AKTIF", "ASKILI YAN", "KATEGORI", "URUN ILK GRUBU", "URUN ANA GRUBU", "URUN ALT GRUBU", "ICERIK", "MARKA", "BRAND CODE", "MARKA GRUBU", "ANCHOR MODE", "HESAP AKTIF", "PG YAYIN", "NEBIM YAYIN", "TRY TOPTAN YUVARLAMA", "TRY PERAKENDE MODU", "TRY PERAKENDE DEGERI", "TRY TABAN", "TRY 1", "TRY 2", "TRY 3", "TRY 4", "TRY 5", "TRY 6", "USD TOPTAN YUVARLAMA", "USD PERAKENDE MODU", "USD PERAKENDE DEGERI", "USD TABAN", "USD 1", "USD 2", "USD 3", "USD 4", "USD 5", "USD 6", "EUR TOPTAN YUVARLAMA", "EUR PERAKENDE MODU", "EUR PERAKENDE DEGERI", "EUR TABAN", "EUR 1", "EUR 2", "EUR 3", "EUR 4", "EUR 5", "EUR 6", } var b strings.Builder for i, h := range headers { b.WriteString(csvEscapeValue(h)) if i == len(headers)-1 { b.WriteString("\n") } else { b.WriteString(";") } } for _, row := range rows { active := "Pasif" if row.Rule == nil || row.Rule.IsActive { active = "Aktif" } values := []string{ map[bool]string{true: "Tanimli", false: "Yeni"}[row.HasRule], active, row.AskiliYan, row.Kategori, row.UrunIlkGrubu, row.UrunAnaGrubu, row.UrunAltGrubu, row.Icerik, csvExcelTextValue(row.Marka), csvExcelTextValue(row.BrandCode), row.BrandGroupSec, pricingRuleStringValue(row, "anchor_mode"), map[bool]string{true: "Aktif", false: "Pasif"}[row.Rule == nil || row.Rule.CalcEnabled], map[bool]string{true: "Evet", false: "Hayir"}[row.Rule == nil || row.Rule.PublishPostgres], map[bool]string{true: "Evet", false: "Hayir"}[row.Rule == nil || row.Rule.PublishNebim], fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "try_wholesale_step")), pricingRuleStringValue(row, "try_retail_mode"), fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "try_retail_step")), fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "try_base")), fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "try1")), fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "try2")), fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "try3")), fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "try4")), fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "try5")), fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "try6")), fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "usd_wholesale_step")), pricingRuleStringValue(row, "usd_retail_mode"), fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "usd_retail_step")), fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "usd_base")), fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "usd1")), fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "usd2")), fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "usd3")), fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "usd4")), fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "usd5")), fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "usd6")), fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "eur_wholesale_step")), pricingRuleStringValue(row, "eur_retail_mode"), fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "eur_retail_step")), fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "eur_base")), fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "eur1")), fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "eur2")), fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "eur3")), fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "eur4")), fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "eur5")), fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "eur6")), } for i, value := range values { b.WriteString(csvEscapeValue(value)) if i == len(values)-1 { b.WriteString("\n") } else { b.WriteString(";") } } } return b.String() } func csvEscapeValue(value string) string { text := strings.ReplaceAll(strings.ReplaceAll(strings.TrimSpace(value), "\r", " "), "\n", " ") if strings.Contains(text, ";") || strings.Contains(text, "\"") { text = `"` + strings.ReplaceAll(text, `"`, `""`) + `"` } return text } func csvExcelTextValue(value string) string { text := strings.TrimSpace(value) if text == "" { return "" } return `="` + strings.ReplaceAll(text, `"`, `""`) + `"` } func splitCSV(raw string) []string { raw = strings.TrimSpace(raw) if raw == "" { return nil } parts := strings.Split(raw, ",") out := make([]string, 0, len(parts)) for _, p := range parts { p = strings.TrimSpace(p) if p != "" { out = append(out, p) } } return out }