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"` } 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.TryStep < 0 || it.UsdStep < 0 || it.EurStep < 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 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_step", "usd_base", "usd1", "usd2", "usd3", "usd4", "usd5", "usd6", "usd_step", "eur_base", "eur1", "eur2", "eur3", "eur4", "eur5", "eur6", "eur_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_step": return row.Rule.TryStep 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_step": return row.Rule.UsdStep 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_step": return row.Rule.EurStep 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 YUVARLAMA", "TRY TABAN", "TRY 1", "TRY 2", "TRY 3", "TRY 4", "TRY 5", "TRY 6", "USD YUVARLAMA", "USD TABAN", "USD 1", "USD 2", "USD 3", "USD 4", "USD 5", "USD 6", "EUR 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_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_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_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 }