package routes import ( "bssapp-backend/auth" "bssapp-backend/models" "bssapp-backend/queries" "context" "encoding/json" "errors" "fmt" "log" "net/http" "strconv" "strings" "time" ) // GET /api/pricing/products func GetProductPricingListHandler(w http.ResponseWriter, r *http.Request) { started := time.Now() traceID := buildPricingTraceID(r) w.Header().Set("X-Trace-ID", traceID) claims, ok := auth.GetClaimsFromContext(r.Context()) if !ok || claims == nil { log.Printf("[ProductPricing] trace=%s unauthorized method=%s path=%s", traceID, r.Method, r.URL.Path) http.Error(w, "unauthorized", http.StatusUnauthorized) return } log.Printf("[ProductPricing] trace=%s start user=%s id=%d", traceID, claims.Username, claims.ID) // Cloudflare upstream timeout is lower than 180s; fail fast and return API 504 instead of CDN 524. ctx, cancel := context.WithTimeout(r.Context(), 110*time.Second) defer cancel() limit := 500 if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" { if parsed, err := strconv.Atoi(raw); err == nil && parsed > 0 && parsed <= 500 { limit = parsed } } page := 1 if raw := strings.TrimSpace(r.URL.Query().Get("page")); raw != "" { if parsed, err := strconv.Atoi(raw); err == nil && parsed > 0 { page = parsed } } filters := queries.ProductPricingFilters{ Search: strings.TrimSpace(r.URL.Query().Get("q")), ProductCode: splitCSVParam(r.URL.Query().Get("product_code")), BrandGroup: splitCSVParam(r.URL.Query().Get("brand_group_selection")), AskiliYan: splitCSVParam(r.URL.Query().Get("askili_yan")), Kategori: splitCSVParam(r.URL.Query().Get("kategori")), UrunIlkGrubu: splitCSVParam(r.URL.Query().Get("urun_ilk_grubu")), UrunAnaGrubu: splitCSVParam(r.URL.Query().Get("urun_ana_grubu")), UrunAltGrubu: splitCSVParam(r.URL.Query().Get("urun_alt_grubu")), Icerik: splitCSVParam(r.URL.Query().Get("icerik")), Karisim: splitCSVParam(r.URL.Query().Get("karisim")), Marka: splitCSVParam(r.URL.Query().Get("marka")), } if len(filters.UrunAnaGrubu) > 3 { http.Error(w, "Urun Ana Grubu en fazla 3 secilebilir", http.StatusBadRequest) return } includeTotal := true if raw := strings.TrimSpace(r.URL.Query().Get("include_total")); raw != "" { if raw == "0" || strings.EqualFold(raw, "false") { includeTotal = false } } // When primary group filters are present, COUNT(*) is acceptable and improves UX // (accurate totalCount/totalPages). Force includeTotal on. if len(filters.UrunIlkGrubu) > 0 || len(filters.UrunAnaGrubu) > 0 { includeTotal = true } sortBy := strings.TrimSpace(r.URL.Query().Get("sort_by")) desc := true if raw := strings.TrimSpace(r.URL.Query().Get("desc")); raw != "" { if raw == "0" || strings.EqualFold(raw, "false") { desc = false } } pageResult, err := queries.GetProductPricingPage(ctx, page, limit, filters, includeTotal, sortBy, desc) if err != nil { if isPricingTimeoutLike(err, ctx.Err()) { log.Printf( "[ProductPricing] trace=%s timeout user=%s id=%d duration_ms=%d err=%v", traceID, claims.Username, claims.ID, time.Since(started).Milliseconds(), err, ) http.Error(w, "Urun fiyatlandirma listesi zaman asimina ugradi", http.StatusGatewayTimeout) return } log.Printf( "[ProductPricing] trace=%s query_error user=%s id=%d duration_ms=%d err=%v", traceID, claims.Username, claims.ID, time.Since(started).Milliseconds(), err, ) http.Error(w, "Urun fiyatlandirma listesi alinamadi: "+err.Error(), http.StatusInternalServerError) return } log.Printf( "[ProductPricing] trace=%s success user=%s id=%d page=%d limit=%d count=%d total=%d total_pages=%d duration_ms=%d", traceID, claims.Username, claims.ID, pageResult.Page, limit, len(pageResult.Rows), pageResult.TotalCount, pageResult.TotalPages, time.Since(started).Milliseconds(), ) w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("X-Total-Count", strconv.Itoa(pageResult.TotalCount)) w.Header().Set("X-Total-Pages", strconv.Itoa(pageResult.TotalPages)) w.Header().Set("X-Page", strconv.Itoa(pageResult.Page)) _ = json.NewEncoder(w).Encode(pageResult.Rows) } // GET /api/pricing/products/options?field=urunAnaGrubu&q=SER&limit=120 func GetProductPricingFilterOptionsHandler(w http.ResponseWriter, r *http.Request) { started := time.Now() traceID := buildPricingTraceID(r) w.Header().Set("X-Trace-ID", traceID) claims, ok := auth.GetClaimsFromContext(r.Context()) if !ok || claims == nil { log.Printf("[ProductPricingOptions] trace=%s unauthorized method=%s path=%s", traceID, r.Method, r.URL.Path) http.Error(w, "unauthorized", http.StatusUnauthorized) return } ctx, cancel := context.WithTimeout(r.Context(), 12*time.Second) defer cancel() field := strings.TrimSpace(r.URL.Query().Get("field")) q := strings.TrimSpace(r.URL.Query().Get("q")) scopeUrunIlkGrubu := splitCSVParam(r.URL.Query().Get("urun_ilk_grubu")) limit := 120 if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" { if parsed, err := strconv.Atoi(raw); err == nil && parsed > 0 && parsed <= 200 { limit = parsed } } items, err := queries.GetProductPricingFilterOptions(ctx, field, q, limit, scopeUrunIlkGrubu) if err != nil { if isPricingTimeoutLike(err, ctx.Err()) { log.Printf( "[ProductPricingOptions] trace=%s timeout user=%s id=%d field=%s q=%s duration_ms=%d err=%v", traceID, claims.Username, claims.ID, field, q, time.Since(started).Milliseconds(), err, ) http.Error(w, "Urun fiyatlandirma filtre secenekleri zaman asimina ugradi", http.StatusGatewayTimeout) return } log.Printf( "[ProductPricingOptions] trace=%s query_error user=%s id=%d field=%s q=%s duration_ms=%d err=%v", traceID, claims.Username, claims.ID, field, q, time.Since(started).Milliseconds(), err, ) http.Error(w, "Filtre secenekleri alinamadi: "+err.Error(), http.StatusInternalServerError) return } type optionItem struct { Label string `json:"label"` Value string `json:"value"` } resp := struct { Field string `json:"field"` Count int `json:"count"` Items []optionItem `json:"items"` }{ Field: field, Count: len(items), Items: make([]optionItem, 0, len(items)), } for _, v := range items { resp.Items = append(resp.Items, optionItem{Label: v, Value: v}) } log.Printf( "[ProductPricingOptions] trace=%s success user=%s id=%d field=%s q=%s count=%d duration_ms=%d", traceID, claims.Username, claims.ID, field, q, len(items), time.Since(started).Milliseconds(), ) w.Header().Set("Content-Type", "application/json; charset=utf-8") _ = json.NewEncoder(w).Encode(resp) } // GET /api/pricing/products/export-all func ExportAllProductPricingHandler(w http.ResponseWriter, r *http.Request) { started := time.Now() traceID := buildPricingTraceID(r) w.Header().Set("X-Trace-ID", traceID) claims, ok := auth.GetClaimsFromContext(r.Context()) if !ok || claims == nil { log.Printf("[ProductPricingExport] trace=%s unauthorized method=%s path=%s", traceID, r.Method, r.URL.Path) http.Error(w, "unauthorized", http.StatusUnauthorized) return } ctx, cancel := context.WithTimeout(r.Context(), 170*time.Second) defer cancel() filters := parseProductPricingFilters(r) if len(filters.UrunAnaGrubu) > 3 { http.Error(w, "Urun Ana Grubu en fazla 3 secilebilir", http.StatusBadRequest) return } sortBy := strings.TrimSpace(r.URL.Query().Get("sort_by")) desc := true if raw := strings.TrimSpace(r.URL.Query().Get("desc")); raw != "" { if raw == "0" || strings.EqualFold(raw, "false") { desc = false } } rows, err := queries.GetAllProductPricingRows(ctx, 1000, filters, sortBy, desc) if err != nil { if isPricingTimeoutLike(err, ctx.Err()) { http.Error(w, "Urun fiyatlandirma export zaman asimina ugradi", http.StatusGatewayTimeout) return } http.Error(w, "Urun fiyatlandirma export alinamadi: "+err.Error(), http.StatusInternalServerError) return } rows = filterProductPricingExportRows(rows, parseProductPricingExportFilters(r)) currencies := parseExportCurrencies(r.URL.Query().Get("currencies")) content := buildProductPricingExportCSV(rows, currencies) filename := fmt.Sprintf("product_pricing_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(content)) log.Printf( "[ProductPricingExport] trace=%s success user=%s id=%d rows=%d duration_ms=%d", traceID, claims.Username, claims.ID, len(rows), time.Since(started).Milliseconds(), ) } type productPricingExportFilters struct { BrandGroupSelection []string ValueFilters map[string][]string StockQtyMin *float64 StockQtyMax *float64 StockEntryFrom string StockEntryTo string LastPricingFrom string LastPricingTo string } func parseProductPricingFilters(r *http.Request) queries.ProductPricingFilters { return queries.ProductPricingFilters{ Search: strings.TrimSpace(r.URL.Query().Get("q")), ProductCode: splitCSVParam(r.URL.Query().Get("product_code")), BrandGroup: splitCSVParam(r.URL.Query().Get("brand_group_selection")), AskiliYan: splitCSVParam(r.URL.Query().Get("askili_yan")), Kategori: splitCSVParam(r.URL.Query().Get("kategori")), UrunIlkGrubu: splitCSVParam(r.URL.Query().Get("urun_ilk_grubu")), UrunAnaGrubu: splitCSVParam(r.URL.Query().Get("urun_ana_grubu")), UrunAltGrubu: splitCSVParam(r.URL.Query().Get("urun_alt_grubu")), Icerik: splitCSVParam(r.URL.Query().Get("icerik")), Karisim: splitCSVParam(r.URL.Query().Get("karisim")), Marka: splitCSVParam(r.URL.Query().Get("marka")), } } func parseProductPricingExportFilters(r *http.Request) productPricingExportFilters { parseFloatPtr := 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 productPricingExportFilters{ BrandGroupSelection: splitCSVParam(r.URL.Query().Get("brand_group_selection_local")), ValueFilters: map[string][]string{ "costPrice": splitCSVParam(r.URL.Query().Get("vf_costPrice")), "expenseForBasePrice": splitCSVParam(r.URL.Query().Get("vf_expenseForBasePrice")), "basePriceUsd": splitCSVParam(r.URL.Query().Get("vf_basePriceUsd")), "basePriceTry": splitCSVParam(r.URL.Query().Get("vf_basePriceTry")), "usd1": splitCSVParam(r.URL.Query().Get("vf_usd1")), "usd2": splitCSVParam(r.URL.Query().Get("vf_usd2")), "usd3": splitCSVParam(r.URL.Query().Get("vf_usd3")), "usd4": splitCSVParam(r.URL.Query().Get("vf_usd4")), "usd5": splitCSVParam(r.URL.Query().Get("vf_usd5")), "usd6": splitCSVParam(r.URL.Query().Get("vf_usd6")), "eur1": splitCSVParam(r.URL.Query().Get("vf_eur1")), "eur2": splitCSVParam(r.URL.Query().Get("vf_eur2")), "eur3": splitCSVParam(r.URL.Query().Get("vf_eur3")), "eur4": splitCSVParam(r.URL.Query().Get("vf_eur4")), "eur5": splitCSVParam(r.URL.Query().Get("vf_eur5")), "eur6": splitCSVParam(r.URL.Query().Get("vf_eur6")), "try1": splitCSVParam(r.URL.Query().Get("vf_try1")), "try2": splitCSVParam(r.URL.Query().Get("vf_try2")), "try3": splitCSVParam(r.URL.Query().Get("vf_try3")), "try4": splitCSVParam(r.URL.Query().Get("vf_try4")), "try5": splitCSVParam(r.URL.Query().Get("vf_try5")), "try6": splitCSVParam(r.URL.Query().Get("vf_try6")), }, StockQtyMin: parseFloatPtr(r.URL.Query().Get("stock_qty_min")), StockQtyMax: parseFloatPtr(r.URL.Query().Get("stock_qty_max")), StockEntryFrom: strings.TrimSpace(r.URL.Query().Get("stock_entry_from")), StockEntryTo: strings.TrimSpace(r.URL.Query().Get("stock_entry_to")), LastPricingFrom: strings.TrimSpace(r.URL.Query().Get("last_pricing_from")), LastPricingTo: strings.TrimSpace(r.URL.Query().Get("last_pricing_to")), } } func filterProductPricingExportRows(rows []models.ProductPricing, f productPricingExportFilters) []models.ProductPricing { if len(rows) == 0 { return rows } out := make([]models.ProductPricing, 0, len(rows)) for _, row := range rows { if len(f.BrandGroupSelection) > 0 && !containsText(f.BrandGroupSelection, row.BrandGroupSec) { continue } if !matchesProductPricingValueFilters(row, f.ValueFilters) { continue } if f.StockQtyMin != nil && row.StockQty < *f.StockQtyMin { continue } if f.StockQtyMax != nil && row.StockQty > *f.StockQtyMax { continue } if !matchesDateRangeString(row.StockEntryDate, f.StockEntryFrom, f.StockEntryTo) { continue } if !matchesDateRangeString(row.LastPricingDate, f.LastPricingFrom, f.LastPricingTo) { continue } out = append(out, row) } return out } func parseExportCurrencies(raw string) []string { requested := splitCSVParam(raw) if len(requested) == 0 { return []string{"USD", "EUR", "TRY"} } out := make([]string, 0, 3) seen := map[string]bool{} for _, item := range requested { cur := strings.ToUpper(strings.TrimSpace(item)) if cur != "USD" && cur != "EUR" && cur != "TRY" { continue } if seen[cur] { continue } seen[cur] = true out = append(out, cur) } if len(out) == 0 { return []string{"USD", "EUR", "TRY"} } return out } func matchesProductPricingValueFilters(row models.ProductPricing, filters map[string][]string) bool { if len(filters) == 0 { return true } for field, selected := range filters { if len(selected) == 0 { continue } if !containsText(selected, productPricingValueKey(row, field)) { return false } } return true } func productPricingValueKey(row models.ProductPricing, field string) string { value := 0.0 switch field { case "costPrice": value = row.CostPrice case "expenseForBasePrice": value = 0 case "basePriceUsd": value = row.BasePriceUsd case "basePriceTry": value = row.BasePriceTry case "usd1": value = row.USD1 case "usd2": value = row.USD2 case "usd3": value = row.USD3 case "usd4": value = row.USD4 case "usd5": value = row.USD5 case "usd6": value = row.USD6 case "eur1": value = row.EUR1 case "eur2": value = row.EUR2 case "eur3": value = row.EUR3 case "eur4": value = row.EUR4 case "eur5": value = row.EUR5 case "eur6": value = row.EUR6 case "try1": value = row.TRY1 case "try2": value = row.TRY2 case "try3": value = row.TRY3 case "try4": value = row.TRY4 case "try5": value = row.TRY5 case "try6": value = row.TRY6 } return fmt.Sprintf("%.2f", value) } func matchesDateRangeString(value string, from string, to string) bool { value = strings.TrimSpace(value) from = strings.TrimSpace(from) to = strings.TrimSpace(to) if from == "" && to == "" { return true } if value == "" { return false } if from != "" && value < from { return false } if to != "" && value > to { return false } return true } func containsText(list []string, value string) bool { value = strings.TrimSpace(value) for _, item := range list { if strings.TrimSpace(item) == value { return true } } return false } func csvEscape(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 csvFloat(value float64) string { return fmt.Sprintf("%.2f", value) } func buildProductPricingExportCSV(rows []models.ProductPricing, currencies []string) string { var b strings.Builder headers := []string{ "MARKA GRUBU SECIMI", "MARKA", "URUN KODU", "STOK ADET", "STOK GIRIS TARIHI", "SON MALIYETLENDIRME", "SON FIYATLANDIRMA TARIHI", "ASKILI YAN", "KATEGORI", "URUN ILK GRUBU", "URUN ANA GRUBU", "URUN ALT GRUBU", "ICERIK", "KARISIM", "MALIYET FIYATI", "TABAN FIYAT MASRAF", "TABAN USD", "TABAN TRY", } for _, h := range headers { b.WriteString(csvEscape(h)) b.WriteString(";") } for idx, cur := range currencies { for tier := 1; tier <= 6; tier++ { b.WriteString(csvEscape(fmt.Sprintf("%s %d", cur, tier))) if idx == len(currencies)-1 && tier == 6 { b.WriteString("\n") } else { b.WriteString(";") } } } for _, row := range rows { base := []string{ row.BrandGroupSec, row.Marka, row.ProductCode, csvFloat(row.StockQty), row.StockEntryDate, row.LastCostingDate, row.LastPricingDate, row.AskiliYan, row.Kategori, row.UrunIlkGrubu, row.UrunAnaGrubu, row.UrunAltGrubu, row.Icerik, row.Karisim, csvFloat(row.CostPrice), csvFloat(0), csvFloat(row.BasePriceUsd), csvFloat(row.BasePriceTry), } for _, item := range base { b.WriteString(csvEscape(item)) b.WriteString(";") } for idx, cur := range currencies { values := productPricingCurrencyValues(row, cur) for tierIdx, value := range values { b.WriteString(csvEscape(csvFloat(value))) if idx == len(currencies)-1 && tierIdx == len(values)-1 { b.WriteString("\n") } else { b.WriteString(";") } } } } return b.String() } func productPricingCurrencyValues(row models.ProductPricing, currency string) []float64 { switch currency { case "USD": return []float64{row.USD1, row.USD2, row.USD3, row.USD4, row.USD5, row.USD6} case "EUR": return []float64{row.EUR1, row.EUR2, row.EUR3, row.EUR4, row.EUR5, row.EUR6} default: return []float64{row.TRY1, row.TRY2, row.TRY3, row.TRY4, row.TRY5, row.TRY6} } } func buildPricingTraceID(r *http.Request) string { if r != nil { if id := strings.TrimSpace(r.Header.Get("X-Request-ID")); id != "" { return id } if id := strings.TrimSpace(r.Header.Get("X-Correlation-ID")); id != "" { return id } } return "pricing-" + strconv.FormatInt(time.Now().UnixNano(), 36) } func isPricingTimeoutLike(err error, ctxErr error) bool { if errors.Is(err, context.DeadlineExceeded) || errors.Is(ctxErr, context.DeadlineExceeded) { return true } if err == nil { return false } e := strings.ToLower(err.Error()) return strings.Contains(e, "timeout") || strings.Contains(e, "i/o timeout") || strings.Contains(e, "wsarecv") || strings.Contains(e, "connection attempt failed") || strings.Contains(e, "no connection could be made") || strings.Contains(e, "failed to respond") } func splitCSVParam(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 == "" { continue } out = append(out, p) } return out }