Files
bssapp/svc/routes/product_pricing.go
2026-06-18 12:28:38 +03:00

753 lines
21 KiB
Go

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))
priceFields := parseExportPriceFields(r.URL.Query().Get("price_fields"))
var content string
if len(priceFields) > 0 {
content = buildProductPricingExportCSVWithPriceFields(rows, priceFields)
} else {
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 parseExportPriceFields(raw string) []string {
requested := splitCSVParam(raw)
if len(requested) == 0 {
return nil
}
out := make([]string, 0, 18)
seen := map[string]bool{}
for _, item := range requested {
v := strings.ToLower(strings.TrimSpace(item))
if v == "" {
continue
}
// Accept both "usd1" and "USD1" etc.
switch v {
case "usd1", "usd2", "usd3", "usd4", "usd5", "usd6",
"eur1", "eur2", "eur3", "eur4", "eur5", "eur6",
"try1", "try2", "try3", "try4", "try5", "try6":
// ok
default:
continue
}
if seen[v] {
continue
}
seen[v] = true
out = append(out, v)
}
if len(out) == 0 {
return nil
}
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 exportPriceFieldTitle(field string) string {
field = strings.ToLower(strings.TrimSpace(field))
if len(field) != 4 {
return strings.ToUpper(field)
}
cur := strings.ToUpper(field[:3])
lv := field[3:]
return fmt.Sprintf("%s %s", cur, lv)
}
func exportPriceFieldValue(row models.ProductPricing, field string) float64 {
switch strings.ToLower(strings.TrimSpace(field)) {
case "usd1":
return row.USD1
case "usd2":
return row.USD2
case "usd3":
return row.USD3
case "usd4":
return row.USD4
case "usd5":
return row.USD5
case "usd6":
return row.USD6
case "eur1":
return row.EUR1
case "eur2":
return row.EUR2
case "eur3":
return row.EUR3
case "eur4":
return row.EUR4
case "eur5":
return row.EUR5
case "eur6":
return row.EUR6
case "try1":
return row.TRY1
case "try2":
return row.TRY2
case "try3":
return row.TRY3
case "try4":
return row.TRY4
case "try5":
return row.TRY5
case "try6":
return row.TRY6
default:
return 0
}
}
func buildProductPricingExportCSVWithPriceFields(rows []models.ProductPricing, priceFields []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 i, pf := range priceFields {
b.WriteString(csvEscape(exportPriceFieldTitle(pf)))
if i == len(priceFields)-1 {
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 i, pf := range priceFields {
b.WriteString(csvEscape(csvFloat(exportPriceFieldValue(row, pf))))
if i == len(priceFields)-1 {
b.WriteString("\n")
} else {
b.WriteString(";")
}
}
}
return b.String()
}
func buildProductPricingExportCSV(rows []models.ProductPricing, currencies []string) string {
// Backward compatible export: USD/EUR/TRY and all 1..6 tiers per currency.
fields := make([]string, 0, 18)
for _, cur := range currencies {
cur = strings.ToUpper(strings.TrimSpace(cur))
switch cur {
case "USD", "EUR", "TRY":
default:
continue
}
for lv := 1; lv <= 6; lv++ {
fields = append(fields, strings.ToLower(fmt.Sprintf("%s%d", cur, lv)))
}
}
if len(fields) == 0 {
fields = []string{"usd1", "usd2", "usd3", "usd4", "usd5", "usd6", "eur1", "eur2", "eur3", "eur4", "eur5", "eur6", "try1", "try2", "try3", "try4", "try5", "try6"}
}
return buildProductPricingExportCSVWithPriceFields(rows, fields)
}
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
}