package routes import ( "bssapp-backend/queries" "bssapp-backend/utils" "database/sql" "encoding/json" "fmt" "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"` 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"` 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"` 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"` } type PricingRuleImportPayload struct { Items []PricingRuleImportItem `json:"items"` } type PricingRuleImportResult struct { Success bool `json:"success"` Processed int `json:"processed"` Updated int `json:"updated"` ActivatedScopeCount int `json:"activated_scope_count"` ErrorCount int `json:"error_count"` } 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 } 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() 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 { http.Error(w, "invalid rounding step", http.StatusBadRequest) return } id, err := queries.UpsertPricingRule(ctx, tx, it) if err != nil { http.Error(w, "pricing rule save error", http.StatusInternalServerError) return } if id != "" { updated++ } } if err := tx.Commit(); err != nil { http.Error(w, "pg transaction commit error", http.StatusInternalServerError) return } _ = 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() updated := 0 activatedScopeCount := 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 } pricingParameterID, activated, err := queries.EnsureActivePricingParameterByScope(ctx, tx, queries.PricingParameterRowForImport( raw.AskiliYan, raw.Kategori, raw.UrunIlkGrubu, raw.UrunAnaGrubu, raw.UrunAltGrubu, raw.Icerik, raw.Marka, raw.BrandCode, raw.BrandGroupSec, )) if err != nil { http.Error(w, "pricing parameter resolve error", http.StatusInternalServerError) return } if activated { activatedScopeCount++ } _, err = queries.UpsertPricingRule(ctx, tx, queries.PricingRuleSaveItem{ PricingParameterID: pricingParameterID, 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, 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, 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, }) 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), Updated: updated, ActivatedScopeCount: activatedScopeCount, 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 "askili_yan", "kategori", "urun_ilk_grubu", "urun_ana_grubu", "urun_alt_grubu", "icerik", "marka", "brand_code", "brand_group": 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 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", "TRY TOPTAN YUVARLAMA", "TRY PERAKENDE YUVARLAMA", "TRY TABAN", "TRY 1", "TRY 2", "TRY 3", "TRY 4", "TRY 5", "TRY 6", "USD TOPTAN YUVARLAMA", "USD PERAKENDE YUVARLAMA", "USD TABAN", "USD 1", "USD 2", "USD 3", "USD 4", "USD 5", "USD 6", "EUR TOPTAN YUVARLAMA", "EUR PERAKENDE YUVARLAMA", "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, row.Marka, row.BrandCode, row.BrandGroupSec, fmt.Sprintf("%.2f", pricingRuleNumericValue(row, "try_wholesale_step")), 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")), 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")), 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 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 }