From b2113a8d49079647216d4ec943812b7d25b4f95e Mon Sep 17 00:00:00 2001 From: M_Kececi Date: Sun, 5 Jul 2026 18:31:32 +0300 Subject: [PATCH] Fix product performance grouped JSON build --- svc/main.go | 5 + svc/models/product_performance.go | 167 ++-- svc/queries/product_performance.go | 843 +++++++++++++++--- svc/queries/product_performance_sql.go | 18 +- svc/routes/product_images.go | 25 + svc/routes/product_performance.go | 58 ++ .../pages/ProductPerformanceProfitability.vue | 287 ++++-- ui/src/stores/productPerformanceStore.js | 56 +- 8 files changed, 1207 insertions(+), 252 deletions(-) diff --git a/svc/main.go b/svc/main.go index acfa5e4..d0d05c0 100644 --- a/svc/main.go +++ b/svc/main.go @@ -999,6 +999,11 @@ func InitRoutes(pgDB *sql.DB, mssql *sql.DB, ml *mailer.GraphMailer) *mux.Router "pricing", "view", wrapV3(routes.GetProductPerformanceGroupedHandler(pgDB)), ) + bindV3(r, pgDB, + "/api/pricing/product-performance/grouped/filter-options", "POST", + "pricing", "view", + wrapV3(routes.GetProductPerformanceGroupedFilterOptionsHandler(pgDB)), + ) bindV3(r, pgDB, "/api/pricing/product-performance/sales-details", "GET", "pricing", "view", diff --git a/svc/models/product_performance.go b/svc/models/product_performance.go index fab9f0e..fb1511a 100644 --- a/svc/models/product_performance.go +++ b/svc/models/product_performance.go @@ -308,82 +308,97 @@ type ProductPerformanceCustomerRow struct { } type ProductPerformanceSalesBreakdownRow struct { - Breakdown string `json:"breakdown"` - ProductCode string `json:"product_code"` - ColorCode string `json:"color_code"` - ColorDescription string `json:"color_description"` - YakaKodu string `json:"yaka_kodu"` - ItemDescription string `json:"item_description"` - Kategori string `json:"kategori"` - AskiliYan string `json:"askili_yan"` - UrunIlkGrubu string `json:"urun_ilk_grubu"` - UrunAnaGrubu string `json:"urun_ana_grubu"` - UrunAltGrubu string `json:"urun_alt_grubu"` - MarketKey string `json:"market_key"` - Country string `json:"country"` - CustomerSegment string `json:"customer_segment"` - CustomerCode string `json:"customer_code"` - CustomerName string `json:"customer_name"` - ProductCount int `json:"product_count"` - CustomerCount90 int `json:"customer_count_90d"` - InvoiceCount90 int `json:"invoice_count_90d"` - SalesQty90 float64 `json:"sales_qty_90d"` - SalesUSD90 float64 `json:"sales_usd_90d"` - AvgPriceUSD90 float64 `json:"avg_price_usd_90d"` - BasePriceUSD90 float64 `json:"base_price_usd_90d"` - CostPriceUSD90 float64 `json:"cost_price_usd_90d"` - GrossProfitBase90 float64 `json:"gross_profit_base_usd_90d"` - GrossProfitCost90 float64 `json:"gross_profit_cost_usd_90d"` - GrossMarginBase90 float64 `json:"gross_margin_base_90d"` - GrossMarginCost90 float64 `json:"gross_margin_cost_90d"` - CustomerCount180 int `json:"customer_count_180d"` - InvoiceCount180 int `json:"invoice_count_180d"` - SalesQty180 float64 `json:"sales_qty_180d"` - SalesUSD180 float64 `json:"sales_usd_180d"` - AvgPriceUSD180 float64 `json:"avg_price_usd_180d"` - BasePriceUSD180 float64 `json:"base_price_usd_180d"` - CostPriceUSD180 float64 `json:"cost_price_usd_180d"` - GrossProfitBase180 float64 `json:"gross_profit_base_usd_180d"` - GrossProfitCost180 float64 `json:"gross_profit_cost_usd_180d"` - GrossMarginBase180 float64 `json:"gross_margin_base_180d"` - GrossMarginCost180 float64 `json:"gross_margin_cost_180d"` - SalesQty365 float64 `json:"sales_qty_365d"` - SalesUSD365 float64 `json:"sales_usd_365d"` - AvgPriceUSD365 float64 `json:"avg_price_usd_365d"` - BasePriceUSD365 float64 `json:"base_price_usd_365d"` - CostPriceUSD365 float64 `json:"cost_price_usd_365d"` - GrossProfitBase365 float64 `json:"gross_profit_base_usd_365d"` - GrossProfitCost365 float64 `json:"gross_profit_cost_usd_365d"` - GrossMarginBase365 float64 `json:"gross_margin_base_365d"` - GrossMarginCost365 float64 `json:"gross_margin_cost_365d"` - CustomerCount365 int `json:"customer_count_365d"` - InvoiceCount365 int `json:"invoice_count_365d"` - CustomerCountTotal int `json:"customer_count_total"` - InvoiceCountTotal int `json:"invoice_count_total"` - SalesQtyTotal float64 `json:"sales_qty_total"` - SalesUSDTotal float64 `json:"sales_usd_total"` - AvgPriceUSDTotal float64 `json:"avg_price_usd_total"` - BasePriceUSDTotal float64 `json:"base_price_usd_total"` - CostPriceUSDTotal float64 `json:"cost_price_usd_total"` - GrossProfitBaseTotal float64 `json:"gross_profit_base_usd_total"` - GrossProfitCostTotal float64 `json:"gross_profit_cost_usd_total"` - GrossMarginBaseTotal float64 `json:"gross_margin_base_total"` - GrossMarginCostTotal float64 `json:"gross_margin_cost_total"` - StockQty float64 `json:"stock_qty"` - StockTurnover90 float64 `json:"stock_turnover_90d"` - StockTurnover180 float64 `json:"stock_turnover_180d"` - StockTurnover365 float64 `json:"stock_turnover_365d"` - StockTurnoverTotal float64 `json:"stock_turnover_total"` - HasCost bool `json:"has_cost"` - SalesIndex90 float64 `json:"sales_index_90d"` - CustomerScore90 float64 `json:"customer_score_90d"` - CustomerScore180 float64 `json:"customer_score_180d"` - CustomerScore365 float64 `json:"customer_score_365d"` - CustomerScoreTotal float64 `json:"customer_score_total"` - PerformanceScore float64 `json:"performance_score"` - PerformanceBucket string `json:"performance_bucket"` - Recommendation string `json:"recommendation"` - LastSaleDate string `json:"last_sale_date"` + Breakdown string `json:"breakdown"` + ProductCode string `json:"product_code"` + ColorCode string `json:"color_code"` + ColorDescription string `json:"color_description"` + YakaKodu string `json:"yaka_kodu"` + ItemDescription string `json:"item_description"` + Kategori string `json:"kategori"` + AskiliYan string `json:"askili_yan"` + UrunIlkGrubu string `json:"urun_ilk_grubu"` + UrunAnaGrubu string `json:"urun_ana_grubu"` + UrunAltGrubu string `json:"urun_alt_grubu"` + MarketKey string `json:"market_key"` + Country string `json:"country"` + CustomerSegment string `json:"customer_segment"` + CustomerCode string `json:"customer_code"` + CustomerName string `json:"customer_name"` + ProductCount int `json:"product_count"` + ProductGroupCount90 int `json:"product_group_count_90d"` + ProductGroupCount180 int `json:"product_group_count_180d"` + ProductGroupCount365 int `json:"product_group_count_365d"` + ProductGroupCountTotal int `json:"product_group_count_total"` + MarketCount90 int `json:"market_count_90d"` + MarketCount180 int `json:"market_count_180d"` + MarketCount365 int `json:"market_count_365d"` + MarketCountTotal int `json:"market_count_total"` + CustomerCount90 int `json:"customer_count_90d"` + InvoiceCount90 int `json:"invoice_count_90d"` + SalesQty90 float64 `json:"sales_qty_90d"` + SalesUSD90 float64 `json:"sales_usd_90d"` + AvgPriceUSD90 float64 `json:"avg_price_usd_90d"` + BasePriceUSD90 float64 `json:"base_price_usd_90d"` + CostPriceUSD90 float64 `json:"cost_price_usd_90d"` + GrossProfitBase90 float64 `json:"gross_profit_base_usd_90d"` + GrossProfitCost90 float64 `json:"gross_profit_cost_usd_90d"` + GrossMarginBase90 float64 `json:"gross_margin_base_90d"` + GrossMarginCost90 float64 `json:"gross_margin_cost_90d"` + CustomerCount180 int `json:"customer_count_180d"` + InvoiceCount180 int `json:"invoice_count_180d"` + SalesQty180 float64 `json:"sales_qty_180d"` + SalesUSD180 float64 `json:"sales_usd_180d"` + AvgPriceUSD180 float64 `json:"avg_price_usd_180d"` + BasePriceUSD180 float64 `json:"base_price_usd_180d"` + CostPriceUSD180 float64 `json:"cost_price_usd_180d"` + GrossProfitBase180 float64 `json:"gross_profit_base_usd_180d"` + GrossProfitCost180 float64 `json:"gross_profit_cost_usd_180d"` + GrossMarginBase180 float64 `json:"gross_margin_base_180d"` + GrossMarginCost180 float64 `json:"gross_margin_cost_180d"` + SalesQty365 float64 `json:"sales_qty_365d"` + SalesUSD365 float64 `json:"sales_usd_365d"` + AvgPriceUSD365 float64 `json:"avg_price_usd_365d"` + BasePriceUSD365 float64 `json:"base_price_usd_365d"` + CostPriceUSD365 float64 `json:"cost_price_usd_365d"` + GrossProfitBase365 float64 `json:"gross_profit_base_usd_365d"` + GrossProfitCost365 float64 `json:"gross_profit_cost_usd_365d"` + GrossMarginBase365 float64 `json:"gross_margin_base_365d"` + GrossMarginCost365 float64 `json:"gross_margin_cost_365d"` + CustomerCount365 int `json:"customer_count_365d"` + InvoiceCount365 int `json:"invoice_count_365d"` + CustomerCountTotal int `json:"customer_count_total"` + InvoiceCountTotal int `json:"invoice_count_total"` + SalesQtyTotal float64 `json:"sales_qty_total"` + SalesUSDTotal float64 `json:"sales_usd_total"` + AvgPriceUSDTotal float64 `json:"avg_price_usd_total"` + BasePriceUSDTotal float64 `json:"base_price_usd_total"` + CostPriceUSDTotal float64 `json:"cost_price_usd_total"` + GrossProfitBaseTotal float64 `json:"gross_profit_base_usd_total"` + GrossProfitCostTotal float64 `json:"gross_profit_cost_usd_total"` + GrossMarginBaseTotal float64 `json:"gross_margin_base_total"` + GrossMarginCostTotal float64 `json:"gross_margin_cost_total"` + StockQty float64 `json:"stock_qty"` + StockTurnover90 float64 `json:"stock_turnover_90d"` + StockTurnover180 float64 `json:"stock_turnover_180d"` + StockTurnover365 float64 `json:"stock_turnover_365d"` + StockTurnoverTotal float64 `json:"stock_turnover_total"` + HasCost bool `json:"has_cost"` + SalesIndex90 float64 `json:"sales_index_90d"` + SalesIndex180 float64 `json:"sales_index_180d"` + SalesIndex365 float64 `json:"sales_index_365d"` + SalesIndexTotal float64 `json:"sales_index_total"` + CustomerScore90 float64 `json:"customer_score_90d"` + CustomerScore180 float64 `json:"customer_score_180d"` + CustomerScore365 float64 `json:"customer_score_365d"` + CustomerScoreTotal float64 `json:"customer_score_total"` + PerformanceScore90 float64 `json:"performance_score_90d"` + PerformanceScore180 float64 `json:"performance_score_180d"` + PerformanceScore365 float64 `json:"performance_score_365d"` + PerformanceScoreTotal float64 `json:"performance_score_total"` + PerformanceScore float64 `json:"performance_score"` + PerformanceBucket string `json:"performance_bucket"` + Recommendation string `json:"recommendation"` + LastSaleDate string `json:"last_sale_date"` } type ProductPerformanceSalesDetailRow struct { diff --git a/svc/queries/product_performance.go b/svc/queries/product_performance.go index bba4215..bf82094 100644 --- a/svc/queries/product_performance.go +++ b/svc/queries/product_performance.go @@ -8,6 +8,7 @@ import ( "encoding/json" "fmt" "log" + "math" "os" "sort" "strconv" @@ -372,10 +373,10 @@ CREATE TABLE IF NOT EXISTS mk_product_performance_sales_daily ( UPDATE mk_product_performance_sales_daily SET urun_ilk_grubu = '', updated_at = now() WHERE btrim(urun_ilk_grubu) = '-' - OR upper(translate(btrim(urun_ilk_grubu), 'İŞĞÜÖÇ', 'ISGUOC')) IN ('YETISKIN', 'YETISKIN/GARSON', 'GARSON')`, + OR upper(translate(btrim(urun_ilk_grubu), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) IN ('YETISKIN', 'YETISKIN/GARSON', 'GARSON')`, ` DELETE FROM mk_product_performance_sales_daily -WHERE upper(btrim(COALESCE(urun_ilk_grubu,''))) IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'DIGER')`, +WHERE upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER')`, ` UPDATE mk_product_performance_sales_daily SET askili_yan = '', updated_at = now() @@ -452,7 +453,7 @@ WHERE cost_price_usd > 0 AND cost_price_usd < base_price_usd`, ` DELETE FROM mk_product_performance_price_dim -WHERE upper(btrim(COALESCE(urun_ilk_grubu,''))) IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'DIGER')`, +WHERE upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER')`, ` CREATE TABLE IF NOT EXISTS mk_product_performance_kpi_daily ( kpi_date DATE NOT NULL, @@ -519,10 +520,10 @@ CREATE TABLE IF NOT EXISTS mk_product_performance_kpi_daily ( UPDATE mk_product_performance_kpi_daily SET urun_ilk_grubu = '', updated_at = now() WHERE btrim(urun_ilk_grubu) = '-' - OR upper(translate(btrim(urun_ilk_grubu), 'İŞĞÜÖÇ', 'ISGUOC')) IN ('YETISKIN', 'YETISKIN/GARSON', 'GARSON')`, + OR upper(translate(btrim(urun_ilk_grubu), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) IN ('YETISKIN', 'YETISKIN/GARSON', 'GARSON')`, ` DELETE FROM mk_product_performance_kpi_daily -WHERE upper(btrim(COALESCE(urun_ilk_grubu,''))) IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'DIGER')`, +WHERE upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER')`, ` UPDATE mk_product_performance_kpi_daily SET @@ -602,6 +603,7 @@ CREATE TABLE IF NOT EXISTS mk_product_performance_grouped_snapshot ( )`, `CREATE INDEX IF NOT EXISTS ix_mk_product_perf_grouped_snapshot_key ON mk_product_performance_grouped_snapshot (report_key, row_order)`, `CREATE INDEX IF NOT EXISTS ix_mk_product_perf_grouped_snapshot_level ON mk_product_performance_grouped_snapshot (report_key, group_level, row_order)`, + `CREATE INDEX IF NOT EXISTS ix_mk_product_perf_grouped_snapshot_field ON mk_product_performance_grouped_snapshot (report_key, group_field, group_value)`, ` CREATE TABLE IF NOT EXISTS mk_product_performance_grouped_snapshot_meta ( report_key TEXT PRIMARY KEY, @@ -1754,6 +1756,7 @@ func ListProductPerformance(ctx context.Context, pg *sql.DB, f ProductPerformanc if rows, ok, err := loadProductPerformanceSnapshotRows[models.ProductPerformanceRow](ctx, pg, productPerformanceSnapshotKey("products"), limit); err != nil { return nil, 0, err } else if ok { + applyProductPerformanceProductRowScores(rows) return rows, len(rows), nil } } @@ -1819,9 +1822,44 @@ LIMIT $`+fmt.Sprint(len(args)-1)+` OFFSET $`+fmt.Sprint(len(args)), args...) for i := range out { out[i].ColorDescription = colorDescriptions[normalizeProductPerformanceCode(out[i].ColorCode)] } + applyProductPerformanceProductRowScores(out) return out, total, nil } +func applyProductPerformanceProductRowScores(rows []models.ProductPerformanceRow) { + avg := productPerformanceProductRowAverages(rows) + for i := range rows { + rows[i].PerformanceScore = productPerformanceProductScore( + "90d", + rows[i].SalesUSD90, + productPerformanceRelativeIndex(rows[i].SalesUSD90, avg.salesUSD90), + rows[i].GrossMargin90, + rows[i].StockTurnover90, + float64(rows[i].MarketCount90), + float64(rows[i].CustomerCount90), + ) + } +} + +type productPerformanceProductRowAverage struct { + salesUSD90 float64 +} + +func productPerformanceProductRowAverages(rows []models.ProductPerformanceRow) productPerformanceProductRowAverage { + var sum90, count90 float64 + for _, row := range rows { + if row.SalesUSD90 > 0 { + sum90 += row.SalesUSD90 + count90++ + } + } + out := productPerformanceProductRowAverage{} + if count90 > 0 { + out.salesUSD90 = sum90 / count90 + } + return out +} + func GetProductPerformanceSummary(ctx context.Context, pg *sql.DB) (models.ProductPerformanceSummary, error) { if err := EnsureProductPerformanceTables(pg); err != nil { return models.ProductPerformanceSummary{}, err @@ -1841,7 +1879,7 @@ KPI AS ( SELECT * FROM mk_product_performance_kpi_daily WHERE kpi_date = (SELECT kpi_date FROM Latest) - AND upper(btrim(COALESCE(urun_ilk_grubu,''))) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'DIGER') + AND upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER') ), VariantStock AS ( SELECT @@ -1868,7 +1906,7 @@ Sales90 AS ( COUNT(DISTINCT NULLIF(customer_code, '-')) AS customer_count_90d FROM mk_product_performance_sales_daily WHERE sales_date BETWEEN (SELECT kpi_date FROM Latest) - INTERVAL '89 days' AND (SELECT kpi_date FROM Latest) - AND upper(btrim(COALESCE(urun_ilk_grubu,''))) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'DIGER') + AND upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER') ) SELECT COALESCE(to_char(MAX(kpi_date),'YYYY-MM-DD'),''), @@ -1904,6 +1942,7 @@ func ListProductPerformanceGeneral(ctx context.Context, pg *sql.DB, limit int) ( if rows, ok, err := loadProductPerformanceSnapshotRows[models.ProductPerformanceGeneralRow](ctx, pg, productPerformanceSnapshotKey("general"), limit); err != nil { return nil, err } else if ok { + applyProductPerformanceGeneralRowScores(rows) return rows, nil } rows, err := pg.QueryContext(ctx, ` @@ -1943,7 +1982,7 @@ SalesAgg AS ( COALESCE(SUM(invoice_count),0)::integer AS invoice_count_total FROM mk_product_performance_sales_daily, Bounds WHERE sales_date BETWEEN Bounds.period_start AND Bounds.period_end - AND upper(btrim(COALESCE(urun_ilk_grubu,''))) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'DIGER') + AND upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER') GROUP BY product_code, color_code, yaka_kodu, market_key ), StockAgg AS ( @@ -1995,7 +2034,7 @@ Dim AS ( 1 AS src_rank FROM mk_product_performance_kpi_daily WHERE kpi_date = (SELECT MAX(kpi_date) FROM mk_product_performance_kpi_daily) - AND upper(btrim(COALESCE(urun_ilk_grubu,''))) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'DIGER') + AND upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER') UNION ALL SELECT product_code, @@ -2011,7 +2050,7 @@ Dim AS ( MAX(urun_alt_grubu) AS urun_alt_grubu, 2 AS src_rank FROM mk_product_performance_sales_daily - WHERE upper(btrim(COALESCE(urun_ilk_grubu,''))) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'DIGER') + WHERE upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER') GROUP BY product_code, color_code, yaka_kodu ) x ORDER BY product_code, color_code, yaka_kodu, src_rank @@ -2043,7 +2082,7 @@ StockOnly AS ( AND d.color_code = s.color_code AND d.yaka_kodu = s.yaka_kodu WHERE s.stock_date = (SELECT stock_date FROM LatestStockDate) - AND upper(btrim(COALESCE(d.urun_ilk_grubu,''))) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'DIGER') + AND upper(translate(btrim(COALESCE(d.urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER') AND NOT EXISTS ( SELECT 1 FROM SalesAgg a @@ -2067,7 +2106,7 @@ Spread AS ( COUNT(DISTINCT NULLIF(customer_code, '-'))::integer AS customer_count_total_all FROM mk_product_performance_sales_daily, Bounds WHERE sales_date BETWEEN Bounds.period_start AND Bounds.period_end - AND upper(btrim(COALESCE(urun_ilk_grubu,''))) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'DIGER') + AND upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER') GROUP BY product_code, color_code, yaka_kodu ), Scored AS ( @@ -2199,9 +2238,28 @@ LIMIT $1 for i := range out { out[i].ColorDescription = colorDescriptions[normalizeProductPerformanceCode(out[i].ColorCode)] } + applyProductPerformanceGeneralRowScores(out) return out, nil } +func applyProductPerformanceGeneralRowScores(rows []models.ProductPerformanceGeneralRow) { + for i := range rows { + turnover := 0.0 + if rows[i].StockQty > 0 { + turnover = rows[i].SalesQtyTotal / rows[i].StockQty + } + rows[i].PerformanceScore = productPerformanceProductScore( + "total", + rows[i].SalesUSDTotal, + rows[i].SalesIndexTotal, + rows[i].GrossMarginTotal, + turnover, + float64(rows[i].MarketCountTotal), + float64(rows[i].CustomerCountTotal), + ) + } +} + func ListProductPerformanceOrderAnalysis(ctx context.Context, pg *sql.DB, limit int) ([]models.ProductPerformanceOrderAnalysisRow, error) { if db.MssqlDB == nil { return nil, fmt.Errorf("mssql db nil") @@ -3061,7 +3119,7 @@ SELECT COALESCE(AVG(NULLIF(stock_days_90d,0)),0) AS avg_stock_days_90d FROM mk_product_performance_kpi_daily WHERE kpi_date = (SELECT MAX(kpi_date) FROM mk_product_performance_kpi_daily) - AND upper(btrim(COALESCE(urun_ilk_grubu,''))) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'DIGER') + AND upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER') GROUP BY market_key ORDER BY risk_stock_cost_value_usd DESC, stock_cost_value_usd DESC, sales_usd_90d DESC LIMIT $1 @@ -3122,7 +3180,7 @@ SELECT COALESCE(SUM(sales_usd) FILTER (WHERE sales_date >= current_date - INTERVAL '364 days'),0) AS sales_usd_365d FROM mk_product_performance_sales_daily WHERE sales_date >= current_date - INTERVAL '364 days' - AND upper(btrim(COALESCE(urun_ilk_grubu,''))) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'DIGER') + AND upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER') GROUP BY customer_country, customer_segment, market_key ORDER BY sales_usd_90d DESC, sales_qty_90d DESC LIMIT $1 @@ -3207,7 +3265,7 @@ SELECT FROM mk_product_performance_sales_daily WHERE sales_date >= current_date - INTERVAL '364 days' AND COALESCE(customer_code,'') <> '' - AND upper(btrim(COALESCE(urun_ilk_grubu,''))) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'DIGER') + AND upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER') GROUP BY %s ORDER BY sales_usd_90d DESC, sales_qty_90d DESC LIMIT $1 @@ -3250,7 +3308,7 @@ func ListProductPerformanceSalesBreakdown(ctx context.Context, pg *sql.DB, break "item_description": "MAX(s.item_description)", "kategori": "COALESCE(MAX(s.kategori),'')", "askili_yan": "COALESCE(MAX(CASE WHEN btrim(COALESCE(s.askili_yan,'')) = '-' THEN '' ELSE s.askili_yan END),'')", - "urun_ilk_grubu": "COALESCE(MAX(CASE WHEN btrim(COALESCE(s.urun_ilk_grubu,'')) = '-' THEN '' WHEN upper(translate(btrim(COALESCE(s.urun_ilk_grubu,'')), 'İŞĞÜÖÇ', 'ISGUOC')) IN ('YETISKIN', 'YETISKIN/GARSON', 'GARSON') THEN '' ELSE s.urun_ilk_grubu END),'')", + "urun_ilk_grubu": "COALESCE(MAX(" + productPerformanceCleanFirstGroupSQL("s.urun_ilk_grubu") + "),'')", "urun_ana_grubu": "COALESCE(MAX(s.urun_ana_grubu),'')", "urun_alt_grubu": "MAX(s.urun_alt_grubu)", "market_key": "s.market_key", @@ -3277,7 +3335,7 @@ func ListProductPerformanceSalesBreakdown(ctx context.Context, pg *sql.DB, break selected["market_key"] = true selected["customer_code"] = true selected["customer_name"] = true - groupCols = []string{"CASE WHEN btrim(COALESCE(s.urun_ilk_grubu,'')) = '-' THEN '' WHEN upper(translate(btrim(COALESCE(s.urun_ilk_grubu,'')), 'İŞĞÜÖÇ', 'ISGUOC')) IN ('YETISKIN', 'YETISKIN/GARSON', 'GARSON') THEN '' ELSE s.urun_ilk_grubu END", "s.color_code", "s.yaka_kodu", "CASE WHEN btrim(COALESCE(s.askili_yan,'')) = '-' THEN '' ELSE s.askili_yan END", "s.kategori", "s.urun_ana_grubu", "s.urun_alt_grubu", "s.product_code", "s.customer_country", "s.market_key", "s.customer_segment", "s.customer_code"} + groupCols = []string{productPerformanceCleanFirstGroupSQL("s.urun_ilk_grubu"), "s.color_code", "s.yaka_kodu", "CASE WHEN btrim(COALESCE(s.askili_yan,'')) = '-' THEN '' ELSE s.askili_yan END", "s.kategori", "s.urun_ana_grubu", "s.urun_alt_grubu", "s.product_code", "s.customer_country", "s.market_key", "s.customer_segment", "s.customer_code"} case "product_country_segment_market_customer": selected["product_code"] = true selected["item_description"] = true @@ -3340,12 +3398,13 @@ func ListProductPerformanceSalesBreakdown(ctx context.Context, pg *sql.DB, break selected["market_key"] = true selected["customer_code"] = true selected["customer_name"] = true - groupCols = []string{"CASE WHEN btrim(COALESCE(s.urun_ilk_grubu,'')) = '-' THEN '' WHEN upper(translate(btrim(COALESCE(s.urun_ilk_grubu,'')), 'İŞĞÜÖÇ', 'ISGUOC')) IN ('YETISKIN', 'YETISKIN/GARSON', 'GARSON') THEN '' ELSE s.urun_ilk_grubu END", "s.color_code", "s.yaka_kodu", "CASE WHEN btrim(COALESCE(s.askili_yan,'')) = '-' THEN '' ELSE s.askili_yan END", "s.kategori", "s.urun_ana_grubu", "s.urun_alt_grubu", "s.product_code", "s.customer_country", "s.market_key", "s.customer_segment", "s.customer_code"} + groupCols = []string{productPerformanceCleanFirstGroupSQL("s.urun_ilk_grubu"), "s.color_code", "s.yaka_kodu", "CASE WHEN btrim(COALESCE(s.askili_yan,'')) = '-' THEN '' ELSE s.askili_yan END", "s.kategori", "s.urun_ana_grubu", "s.urun_alt_grubu", "s.product_code", "s.customer_country", "s.market_key", "s.customer_segment", "s.customer_code"} } if rows, ok, err := loadProductPerformanceSnapshotRows[models.ProductPerformanceSalesBreakdownRow](ctx, pg, productPerformanceSnapshotKey("sales-breakdown", mode), limit); err != nil { return nil, err } else if ok { + applyProductPerformanceSalesBreakdownScores(rows) return rows, nil } @@ -3366,14 +3425,14 @@ WITH LatestKPI AS ( CASE WHEN btrim(COALESCE(askili_yan,'')) = '-' THEN '' ELSE COALESCE(askili_yan,'') END AS askili_yan, CASE WHEN btrim(COALESCE(urun_ilk_grubu,'')) = '-' THEN '' - WHEN upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), 'İŞĞÜÖÇ', 'ISGUOC')) IN ('YETISKIN', 'YETISKIN/GARSON', 'GARSON') THEN '' + WHEN upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) IN ('YETISKIN', 'YETISKIN/GARSON', 'GARSON') THEN '' ELSE COALESCE(urun_ilk_grubu,'') END AS urun_ilk_grubu, COALESCE(urun_ana_grubu,'') AS urun_ana_grubu, urun_alt_grubu FROM mk_product_performance_kpi_daily WHERE kpi_date = (SELECT MAX(kpi_date) FROM mk_product_performance_kpi_daily) - AND upper(btrim(COALESCE(urun_ilk_grubu,''))) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'DIGER') + AND upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER') ORDER BY product_code, color_code, yaka_kodu, performance_score DESC ), StockAgg AS ( @@ -3444,6 +3503,14 @@ Agg AS ( %s, %s, COUNT(DISTINCT product_code) AS product_count, + COUNT(DISTINCT COALESCE(NULLIF(urun_ana_grubu,''), NULLIF(urun_alt_grubu,''), NULLIF(kategori,''), product_code)) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days')::integer AS product_group_count_90d, + COUNT(DISTINCT COALESCE(NULLIF(urun_ana_grubu,''), NULLIF(urun_alt_grubu,''), NULLIF(kategori,''), product_code)) FILTER (WHERE sales_date >= current_date - INTERVAL '179 days')::integer AS product_group_count_180d, + COUNT(DISTINCT COALESCE(NULLIF(urun_ana_grubu,''), NULLIF(urun_alt_grubu,''), NULLIF(kategori,''), product_code)) FILTER (WHERE sales_date >= current_date - INTERVAL '359 days')::integer AS product_group_count_365d, + COUNT(DISTINCT COALESCE(NULLIF(urun_ana_grubu,''), NULLIF(urun_alt_grubu,''), NULLIF(kategori,''), product_code)) FILTER (WHERE sales_date >= DATE '2022-01-01')::integer AS product_group_count_total, + COUNT(DISTINCT NULLIF(NULLIF(market_key,'STOK'),'')) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days')::integer AS market_count_90d, + COUNT(DISTINCT NULLIF(NULLIF(market_key,'STOK'),'')) FILTER (WHERE sales_date >= current_date - INTERVAL '179 days')::integer AS market_count_180d, + COUNT(DISTINCT NULLIF(NULLIF(market_key,'STOK'),'')) FILTER (WHERE sales_date >= current_date - INTERVAL '359 days')::integer AS market_count_365d, + COUNT(DISTINCT NULLIF(NULLIF(market_key,'STOK'),'')) FILTER (WHERE sales_date >= DATE '2022-01-01')::integer AS market_count_total, COALESCE(MAX(stock_qty),0) AS stock_qty, COALESCE(MAX(avg_stock_90d),0) AS avg_stock_90d, COALESCE(MAX(avg_stock_180d),0) AS avg_stock_180d, @@ -3534,7 +3601,7 @@ Agg AS ( LEFT JOIN StockTotalStart stotal ON stotal.product_code = s.product_code AND stotal.color_code = s.color_code AND stotal.yaka_kodu = s.yaka_kodu ) s WHERE sales_date >= DATE '2022-01-01' - AND upper(btrim(COALESCE(urun_ilk_grubu,''))) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'DIGER') + AND upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER') GROUP BY %s ), Enriched AS ( @@ -3584,6 +3651,14 @@ SELECT customer_code, customer_name, product_count, + product_group_count_90d, + product_group_count_180d, + product_group_count_365d, + product_group_count_total, + market_count_90d, + market_count_180d, + market_count_365d, + market_count_total, stock_qty, customer_count_90d, invoice_count_90d, @@ -3690,8 +3765,8 @@ SELECT ELSE 'TAKIP' END AS performance_bucket, CASE - WHEN sales_index_90d >= 1.25 AND customer_count_90d >= 2 THEN 'Güçlü satış kırılımı' - WHEN sales_qty_90d = 0 AND sales_qty_365d > 0 THEN 'Son 90 gün zayıf, kontrol et' + WHEN sales_index_90d >= 1.25 AND customer_count_90d >= 2 THEN 'Guclu satis kirilimi' + WHEN sales_qty_90d = 0 AND sales_qty_365d > 0 THEN 'Son 90 gun zayif, kontrol et' ELSE 'Takip' END AS recommendation, last_sale_date @@ -3729,7 +3804,9 @@ LIMIT $1 &r.Breakdown, &r.ProductCode, &r.ColorCode, &r.YakaKodu, &r.ItemDescription, &r.Kategori, &r.AskiliYan, &r.UrunIlkGrubu, &r.UrunAnaGrubu, &r.UrunAltGrubu, &r.MarketKey, &r.Country, &r.CustomerSegment, &r.CustomerCode, &r.CustomerName, - &r.ProductCount, &r.StockQty, &r.CustomerCount90, &r.InvoiceCount90, &r.SalesQty90, &r.SalesUSD90, + &r.ProductCount, &r.ProductGroupCount90, &r.ProductGroupCount180, &r.ProductGroupCount365, &r.ProductGroupCountTotal, + &r.MarketCount90, &r.MarketCount180, &r.MarketCount365, &r.MarketCountTotal, + &r.StockQty, &r.CustomerCount90, &r.InvoiceCount90, &r.SalesQty90, &r.SalesUSD90, &r.AvgPriceUSD90, &r.BasePriceUSD90, &r.CostPriceUSD90, &r.GrossProfitBase90, &r.GrossProfitCost90, &r.GrossMarginBase90, &r.GrossMarginCost90, &r.CustomerCount180, &r.InvoiceCount180, &r.SalesQty180, &r.SalesUSD180, &r.AvgPriceUSD180, &r.BasePriceUSD180, @@ -3755,9 +3832,52 @@ LIMIT $1 for i := range out { out[i].ColorDescription = colorDescriptions[normalizeProductPerformanceCode(out[i].ColorCode)] } + applyProductPerformanceSalesBreakdownScores(out) return out, nil } +func applyProductPerformanceSalesBreakdownScores(rows []models.ProductPerformanceSalesBreakdownRow) { + avg90 := productPerformanceSalesBreakdownAverage(rows, func(row models.ProductPerformanceSalesBreakdownRow) float64 { return row.SalesUSD90 }) + avg180 := productPerformanceSalesBreakdownAverage(rows, func(row models.ProductPerformanceSalesBreakdownRow) float64 { return row.SalesUSD180 }) + avg365 := productPerformanceSalesBreakdownAverage(rows, func(row models.ProductPerformanceSalesBreakdownRow) float64 { return row.SalesUSD365 }) + avgTotal := productPerformanceSalesBreakdownAverage(rows, func(row models.ProductPerformanceSalesBreakdownRow) float64 { return row.SalesUSDTotal }) + + for i := range rows { + r := &rows[i] + r.SalesIndex90 = productPerformanceRelativeIndex(r.SalesUSD90, avg90) + r.SalesIndex180 = productPerformanceRelativeIndex(r.SalesUSD180, avg180) + r.SalesIndex365 = productPerformanceRelativeIndex(r.SalesUSD365, avg365) + r.SalesIndexTotal = productPerformanceRelativeIndex(r.SalesUSDTotal, avgTotal) + + r.CustomerScore90 = productPerformanceCustomerScore("90d", r.SalesUSD90, r.GrossMarginCost90, float64(r.ProductGroupCount90), r.SalesQty90) + r.CustomerScore180 = productPerformanceCustomerScore("180d", r.SalesUSD180, r.GrossMarginCost180, float64(r.ProductGroupCount180), r.SalesQty180) + r.CustomerScore365 = productPerformanceCustomerScore("365d", r.SalesUSD365, r.GrossMarginCost365, float64(r.ProductGroupCount365), r.SalesQty365) + r.CustomerScoreTotal = productPerformanceCustomerScore("total", r.SalesUSDTotal, r.GrossMarginCostTotal, float64(r.ProductGroupCountTotal), r.SalesQtyTotal) + + r.PerformanceScore90 = productPerformanceProductScore("90d", r.SalesUSD90, r.SalesIndex90, r.GrossMarginCost90, r.StockTurnover90, float64(r.MarketCount90), float64(r.CustomerCount90)) + r.PerformanceScore180 = productPerformanceProductScore("180d", r.SalesUSD180, r.SalesIndex180, r.GrossMarginCost180, r.StockTurnover180, float64(r.MarketCount180), float64(r.CustomerCount180)) + r.PerformanceScore365 = productPerformanceProductScore("365d", r.SalesUSD365, r.SalesIndex365, r.GrossMarginCost365, r.StockTurnover365, float64(r.MarketCount365), float64(r.CustomerCount365)) + r.PerformanceScoreTotal = productPerformanceProductScore("total", r.SalesUSDTotal, r.SalesIndexTotal, r.GrossMarginCostTotal, r.StockTurnoverTotal, float64(r.MarketCountTotal), float64(r.CustomerCountTotal)) + r.PerformanceScore = r.PerformanceScore90 + } +} + +func productPerformanceSalesBreakdownAverage(rows []models.ProductPerformanceSalesBreakdownRow, value func(models.ProductPerformanceSalesBreakdownRow) float64) float64 { + var sum, count float64 + for _, row := range rows { + v := value(row) + if v <= 0 { + continue + } + sum += v + count++ + } + if count == 0 { + return 0 + } + return sum / count +} + type ProductPerformanceGroupedRequest struct { Mode string GroupLevels []string @@ -3770,6 +3890,14 @@ type ProductPerformanceGroupedRequest struct { Descending bool } +type ProductPerformanceGroupedFilterOptionsRequest struct { + Mode string + GroupLevels []string + MainGroup string + Fields []string + Limit int +} + func ListProductPerformanceGrouped(ctx context.Context, pg *sql.DB, req ProductPerformanceGroupedRequest) ([]map[string]any, error) { if err := EnsureProductPerformanceTables(pg); err != nil { return nil, err @@ -3805,12 +3933,114 @@ func ListProductPerformanceGrouped(ctx context.Context, pg *sql.DB, req ProductP return out, nil } +func ListProductPerformanceGroupedFilterOptions(ctx context.Context, pg *sql.DB, req ProductPerformanceGroupedFilterOptionsRequest) (map[string][]string, error) { + if err := EnsureProductPerformanceTables(pg); err != nil { + return nil, err + } + if req.Limit <= 0 || req.Limit > 5000 { + req.Limit = 5000 + } + levels := sanitizeProductPerformanceGroupLevels(req.GroupLevels) + if len(levels) == 0 { + levels = defaultProductPerformanceGroupLevels(req.Mode) + } + reportKey := productPerformanceGroupedSnapshotReportKey(req.Mode, levels, req.MainGroup) + if strings.TrimSpace(reportKey) == "" { + return map[string][]string{}, nil + } + levelSet := map[string]bool{} + for _, level := range levels { + levelSet[level] = true + } + out := map[string][]string{} + for _, field := range req.Fields { + field = strings.TrimSpace(field) + if !productPerformanceGroupedFilterOptionFieldAllowed(field) { + continue + } + values, err := productPerformanceGroupedSnapshotFilterOptions(ctx, pg, reportKey, field, levelSet, strings.TrimSpace(req.MainGroup), req.Limit) + if err != nil { + return nil, err + } + out[field] = values + } + return out, nil +} + +func productPerformanceGroupedFilterOptionFieldAllowed(field string) bool { + switch field { + case "kategori", "askili_yan", "urun_ilk_grubu", "urun_ana_grubu", "urun_alt_grubu", + "product_code", "color_yaka", "market_key", "country", "customer_segment", + "customer_code", "customer_name", "performance_bucket": + return true + default: + return false + } +} + +func productPerformanceGroupedSnapshotFilterOptions(ctx context.Context, pg *sql.DB, reportKey, field string, levelSet map[string]bool, mainGroup string, limit int) ([]string, error) { + if field == "urun_ana_grubu" && strings.Contains(reportKey, ":product_detail:") && strings.TrimSpace(mainGroup) != "" { + return []string{strings.TrimSpace(mainGroup)}, nil + } + if field == "performance_bucket" { + return productPerformanceGroupedSnapshotPayloadOptions(ctx, pg, reportKey, "performance_bucket", limit) + } + if !levelSet[field] { + return []string{}, nil + } + rows, err := pg.QueryContext(ctx, ` +SELECT DISTINCT btrim(group_value) AS value +FROM mk_product_performance_grouped_snapshot +WHERE report_key = $1 + AND group_field = $2 + AND btrim(group_value) <> '' +ORDER BY value +LIMIT $3 +`, reportKey, field, limit) + if err != nil { + return nil, err + } + defer rows.Close() + return scanProductPerformanceGroupedFilterOptionRows(rows) +} + +func productPerformanceGroupedSnapshotPayloadOptions(ctx context.Context, pg *sql.DB, reportKey, field string, limit int) ([]string, error) { + rows, err := pg.QueryContext(ctx, ` +SELECT DISTINCT btrim(payload ->> $2) AS value +FROM mk_product_performance_grouped_snapshot +WHERE report_key = $1 + AND btrim(payload ->> $2) <> '' +ORDER BY value +LIMIT $3 +`, reportKey, field, limit) + if err != nil { + return nil, err + } + defer rows.Close() + return scanProductPerformanceGroupedFilterOptionRows(rows) +} + +func scanProductPerformanceGroupedFilterOptionRows(rows *sql.Rows) ([]string, error) { + out := []string{} + for rows.Next() { + var value string + if err := rows.Scan(&value); err != nil { + return nil, err + } + value = strings.TrimSpace(value) + if value != "" { + out = append(out, value) + } + } + return out, rows.Err() +} + func loadProductPerformancePreparedGroupedRows(ctx context.Context, pg *sql.DB, req ProductPerformanceGroupedRequest, levels []string) ([]map[string]any, bool, error) { reportKey := productPerformanceGroupedSnapshotReportKey(req.Mode, levels, req.MainGroup) if strings.TrimSpace(reportKey) == "" { return nil, false, nil } - effectiveFilters := productPerformanceGroupedEffectiveFilters(req) + effectiveFilters := productPerformancePreparedGroupedEffectiveFilters(req) hasFilters := len(effectiveFilters) > 0 hasManualExpansion := len(req.ExpandedKeys) > 0 query := ` @@ -4065,6 +4295,21 @@ func productPerformanceGroupedEffectiveFilters(req ProductPerformanceGroupedRequ return filters } +func productPerformancePreparedGroupedEffectiveFilters(req ProductPerformanceGroupedRequest) map[string][]string { + filters := make(map[string][]string, len(req.Filters)) + for field, values := range req.Filters { + if strings.TrimSpace(req.Mode) == "product_detail" && field == "urun_ana_grubu" { + continue + } + cleanValues := cleanProductPerformanceFilterValues(values) + if len(cleanValues) == 0 { + continue + } + filters[field] = cleanValues + } + return filters +} + func appendProductPerformanceGroupedSQLRows(ctx context.Context, pg *sql.DB, out *[]map[string]any, mode string, levels []string, level int, visualLevel int, parentKeys []string, filters []productPerformanceSQLGroupFilter, expandedKeys map[string]bool, expandThroughLevel int, limit int) error { if level >= len(levels) { return nil @@ -4371,7 +4616,7 @@ Source AS ( CASE WHEN btrim(COALESCE(askili_yan,'')) = '-' THEN '' ELSE COALESCE(askili_yan,'') END AS askili_yan, CASE WHEN btrim(COALESCE(urun_ilk_grubu,'')) = '-' THEN '' - WHEN upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), 'İŞĞÜÖÇ', 'ISGUOC')) IN ('YETISKIN', 'YETISKIN/GARSON', 'GARSON') THEN '' + WHEN upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) IN ('YETISKIN', 'YETISKIN/GARSON', 'GARSON') THEN '' ELSE COALESCE(urun_ilk_grubu,'') END AS urun_ilk_grubu, COALESCE(urun_ana_grubu,'') AS urun_ana_grubu, @@ -4418,14 +4663,14 @@ Source AS ( COALESCE(stock_qty,0) * COALESCE(cost_price_usd,0) AS idle_cost_usd FROM mk_product_performance_kpi_daily WHERE kpi_date = (SELECT MAX(kpi_date) FROM mk_product_performance_kpi_daily) - AND upper(btrim(COALESCE(urun_ilk_grubu,''))) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'DIGER')` + idleWhere + ` + AND upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER')` + idleWhere + ` )` } func productPerformanceSQLGroupExpr(field string) (string, bool) { switch field { case "urun_ilk_grubu": - return "CASE WHEN btrim(COALESCE(urun_ilk_grubu,'')) = '-' THEN '' WHEN upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), 'İŞĞÜÖÇ', 'ISGUOC')) IN ('YETISKIN', 'YETISKIN/GARSON', 'GARSON') THEN '' ELSE COALESCE(urun_ilk_grubu,'') END", true + return productPerformanceCleanFirstGroupSQL("urun_ilk_grubu"), true case "askili_yan": return "CASE WHEN btrim(COALESCE(askili_yan,'')) = '-' THEN '' ELSE COALESCE(askili_yan,'') END", true case "urun_ana_grubu": @@ -4550,23 +4795,23 @@ func filterProductPerformancePreparedGroupedRows(rows []map[string]any, filters return rows } + rowByKey := make(map[string]map[string]any, len(rows)) + for _, row := range rows { + if key := stringFromMap(row, "key"); key != "" { + rowByKey[key] = row + } + } + matchedKeys := map[string]bool{} allowedKeys := map[string]bool{} for _, row := range rows { - matches := true - for field, allowed := range cleanFilters { - if !allowed[productPerformanceGroupedFilterValue(row, field)] { - matches = false - break - } - } - if !matches { - continue - } key := stringFromMap(row, "key") if key == "" { continue } + if !productPerformancePreparedGroupedPathMatches(key, rowByKey, cleanFilters) { + continue + } matchedKeys[key] = true for _, ancestor := range productPerformanceGroupKeyAncestors(key, true) { allowedKeys[ancestor] = true @@ -4585,6 +4830,49 @@ func filterProductPerformancePreparedGroupedRows(rows []map[string]any, filters return out } +func productPerformancePreparedGroupedPathMatches(key string, rowByKey map[string]map[string]any, filters map[string]map[string]bool) bool { + pathKeys := productPerformanceGroupKeyAncestors(key, true) + if len(pathKeys) == 0 { + pathKeys = []string{key} + } + for field, allowed := range filters { + matched := false + for _, pathKey := range pathKeys { + row := rowByKey[pathKey] + if row == nil { + continue + } + if allowed[productPerformancePreparedGroupedFilterValue(row, field)] { + matched = true + break + } + } + if !matched { + return false + } + } + return true +} + +func productPerformancePreparedGroupedFilterValue(row map[string]any, field string) string { + field = strings.TrimSpace(field) + groupField := strings.TrimSpace(stringFromMap(row, "group_field")) + if groupField == field { + return normalizeProductPerformanceGroupValue(stringFromMap(row, "group_value")) + } + switch field { + case "market_key", "color_yaka", "urun_ilk_grubu", "askili_yan": + value := normalizeProductPerformanceGroupValue(mapGroupValue(row, field)) + if value != "" { + return value + } + } + if value := normalizeProductPerformanceGroupValue(stringFromMap(row, field)); value != "" { + return value + } + return "" +} + func productPerformanceGroupRowHasMatchedAncestor(key string, matchedKeys map[string]bool) bool { for _, ancestor := range productPerformanceGroupKeyAncestors(key, true) { if matchedKeys[ancestor] { @@ -4728,9 +5016,61 @@ func productPerformanceGroupedRawSnapshotSourceRows(ctx context.Context, pg *sql if strings.TrimSpace(mode) == "idle" { return productPerformanceIdleSourceRows(rows), nil } + if mode == "products" || mode == "product_detail" { + rows = mergeProductPerformanceGeneralSnapshotMetrics(ctx, pg, rows) + } return rows, nil } +func mergeProductPerformanceGeneralSnapshotMetrics(ctx context.Context, pg *sql.DB, rows []map[string]any) []map[string]any { + if len(rows) == 0 { + return rows + } + generalRows, ok, err := loadProductPerformanceSnapshotMapRows(ctx, pg, productPerformanceSnapshotKey("general"), 50000) + if err != nil || !ok || len(generalRows) == 0 { + return rows + } + byKey := make(map[string]map[string]any, len(generalRows)) + for _, row := range generalRows { + key := productPerformanceMapMarketVariantKey(row) + if key != "" { + byKey[key] = row + } + } + for _, row := range rows { + general := byKey[productPerformanceMapMarketVariantKey(row)] + if general == nil { + continue + } + for _, field := range []string{ + "market_count_total", "customer_count_total", "invoice_count_total", "sales_index_total", + "avg_price_usd_total", "gross_profit_usd_total", "gross_margin_total", + "unit_profit_cost_total", "unit_profit_base_total", "first_sale_date", + } { + if value, ok := general[field]; ok { + row[field] = value + } + } + if value, ok := general["performance_score"]; ok { + row["performance_score_total"] = value + } + } + return rows +} + +func productPerformanceMapMarketVariantKey(row map[string]any) string { + productCode := normalizeProductPerformanceProductCode(stringFromMap(row, "product_code")) + if productCode == "" { + return "" + } + return strings.Join([]string{ + productCode, + strings.TrimSpace(stringFromMap(row, "color_code")), + strings.TrimSpace(stringFromMap(row, "yaka_kodu")), + displayProductPerformanceMarketName(stringFromMap(row, "market_key")), + }, "|") +} + func productPerformanceGroupedSnapshotRows(ctx context.Context, pg *sql.DB, mode string, limit int) ([]map[string]any, bool, error) { reportKey, ok := productPerformanceGroupedSnapshotKey(mode) if !ok { @@ -4823,19 +5163,23 @@ type productPerformanceGroupedSnapshotAvgState struct { } type productPerformanceGroupedSnapshotNode struct { - Key string - Level int - Field string - Value string - Row map[string]any - Count int - Children map[string]*productPerformanceGroupedSnapshotNode - ChildOrder []string - StockSeen map[string]bool - IdleSeen map[string]bool - Avg map[string]*productPerformanceGroupedSnapshotAvgState - BucketCounts map[string]int - Image map[string]any + Key string + Level int + Field string + Value string + Row map[string]any + Count int + Children map[string]*productPerformanceGroupedSnapshotNode + ChildOrder []string + StockSeen map[string]bool + IdleSeen map[string]bool + Avg map[string]*productPerformanceGroupedSnapshotAvgState + BucketCounts map[string]int + Image map[string]any + Market90Seen map[string]bool + MarketTotalSeen map[string]bool + Customer90Seen map[string]bool + CustomerTotalSeen map[string]bool } func buildProductPerformanceGroupedSnapshotRows(sourceRows []map[string]any, levels []string, mode string) []map[string]any { @@ -4921,8 +5265,6 @@ func (n *productPerformanceGroupedSnapshotNode) add(row map[string]any) { n.IdleSeen[variantKey] = true n.Row[key] = floatFromAny(n.Row[key]) + floatFromAny(value) } - case shouldSumProductPerformanceField(key): - n.Row[key] = floatFromAny(n.Row[key]) + floatFromAny(value) case shouldAverageProductPerformanceField(key): if n.Avg == nil { n.Avg = map[string]*productPerformanceGroupedSnapshotAvgState{} @@ -4940,12 +5282,48 @@ func (n *productPerformanceGroupedSnapshotNode) add(row map[string]any) { } state.Sum += number state.Count++ + case shouldSumProductPerformanceField(key): + n.Row[key] = floatFromAny(n.Row[key]) + floatFromAny(value) default: if _, ok := n.Row[key]; !ok { n.Row[key] = value } } } + n.addDistinctSpread(row) +} + +func (n *productPerformanceGroupedSnapshotNode) addDistinctSpread(row map[string]any) { + market := displayProductPerformanceMarketName(stringFromMap(row, "market_key")) + if market != "" && market != "STOK" { + if floatFromMap(row, "sales_qty_90d") > 0 || floatFromMap(row, "sales_usd_90d") > 0 { + if n.Market90Seen == nil { + n.Market90Seen = map[string]bool{} + } + n.Market90Seen[market] = true + } + if floatFromMap(row, "sales_qty_total") > 0 || floatFromMap(row, "sales_usd_total") > 0 { + if n.MarketTotalSeen == nil { + n.MarketTotalSeen = map[string]bool{} + } + n.MarketTotalSeen[market] = true + } + } + customer := strings.TrimSpace(stringFromMap(row, "customer_code")) + if customer != "" && customer != "-" { + if floatFromMap(row, "sales_qty_90d") > 0 || floatFromMap(row, "sales_usd_90d") > 0 { + if n.Customer90Seen == nil { + n.Customer90Seen = map[string]bool{} + } + n.Customer90Seen[customer] = true + } + if floatFromMap(row, "sales_qty_total") > 0 || floatFromMap(row, "sales_usd_total") > 0 { + if n.CustomerTotalSeen == nil { + n.CustomerTotalSeen = map[string]bool{} + } + n.CustomerTotalSeen[customer] = true + } + } } func appendProductPerformanceGroupedSnapshotNodes(out *[]map[string]any, nodes map[string]*productPerformanceGroupedSnapshotNode, order []string) { @@ -4985,6 +5363,7 @@ func (n *productPerformanceGroupedSnapshotNode) snapshotRow() map[string]any { if bucket := n.dominantBucket(); bucket != "" { row["performance_bucket"] = bucket } + applyProductPerformanceDistinctSpread(row, n.Market90Seen, n.MarketTotalSeen, n.Customer90Seen, n.CustomerTotalSeen) clearProductPerformanceGroupDimensions(row, n.Field, n.Value) row["__group"] = true row["row_key"] = "group|" + n.Key @@ -4994,7 +5373,7 @@ func (n *productPerformanceGroupedSnapshotNode) snapshotRow() map[string]any { row["group_value"] = n.Value row["label"] = n.Value row["count"] = n.Count - row["recommendation"] = fmt.Sprintf("%d satir", n.Count) + row["recommendation"] = productPerformanceGroupRecommendation(row, n.Field, n.Count) image := n.Image if image == nil { image = row @@ -5063,7 +5442,7 @@ func makeProductPerformanceGroupedRow(key string, level int, field, value string row["group_value"] = value row["label"] = value row["count"] = len(rows) - row["recommendation"] = fmt.Sprintf("%d satır", len(rows)) + row["recommendation"] = productPerformanceGroupRecommendation(row, field, len(rows)) image := firstProductPerformanceImageSource(rows) row["image_product_code"] = stringFromMap(image, "product_code") @@ -5101,7 +5480,12 @@ func clearProductPerformanceGroupDimensions(row map[string]any, groupField, grou func aggregateProductPerformanceRows(rows []map[string]any, groupField string) map[string]any { out := map[string]any{} + market90Seen := map[string]bool{} + marketTotalSeen := map[string]bool{} + customer90Seen := map[string]bool{} + customerTotalSeen := map[string]bool{} for _, row := range rows { + addProductPerformanceDistinctSpread(row, market90Seen, marketTotalSeen, customer90Seen, customerTotalSeen) for key, value := range row { if key == "row_key" || key == "key" { continue @@ -5117,9 +5501,12 @@ func aggregateProductPerformanceRows(rows []map[string]any, groupField string) m out[key] = distinctProductPerformanceVariantStockCost(rows) continue } + if shouldAverageProductPerformanceField(key) { + continue + } if shouldSumProductPerformanceField(key) { out[key] = floatFromAny(out[key]) + floatFromAny(value) - } else if _, ok := out[key]; !ok && !shouldAverageProductPerformanceField(key) { + } else if _, ok := out[key]; !ok { out[key] = value } } @@ -5136,9 +5523,96 @@ func aggregateProductPerformanceRows(rows []map[string]any, groupField string) m } deriveProductPerformanceGroupMetrics(out, groupField) out["performance_bucket"] = dominantProductPerformanceValue(rows, "performance_bucket") + applyProductPerformanceDistinctSpread(out, market90Seen, marketTotalSeen, customer90Seen, customerTotalSeen) return out } +func addProductPerformanceDistinctSpread(row map[string]any, market90Seen, marketTotalSeen, customer90Seen, customerTotalSeen map[string]bool) { + market := displayProductPerformanceMarketName(stringFromMap(row, "market_key")) + if market != "" && market != "STOK" { + if floatFromMap(row, "sales_qty_90d") > 0 || floatFromMap(row, "sales_usd_90d") > 0 { + market90Seen[market] = true + } + if floatFromMap(row, "sales_qty_total") > 0 || floatFromMap(row, "sales_usd_total") > 0 { + marketTotalSeen[market] = true + } + } + customer := strings.TrimSpace(stringFromMap(row, "customer_code")) + if customer != "" && customer != "-" { + if floatFromMap(row, "sales_qty_90d") > 0 || floatFromMap(row, "sales_usd_90d") > 0 { + customer90Seen[customer] = true + } + if floatFromMap(row, "sales_qty_total") > 0 || floatFromMap(row, "sales_usd_total") > 0 { + customerTotalSeen[customer] = true + } + } +} + +func applyProductPerformanceDistinctSpread(row map[string]any, market90Seen, marketTotalSeen, customer90Seen, customerTotalSeen map[string]bool) { + if len(market90Seen) > 0 { + row["market_count_90d"] = len(market90Seen) + } + if len(marketTotalSeen) > 0 { + row["market_count_total"] = len(marketTotalSeen) + } + if len(customer90Seen) > 0 { + row["customer_count_90d"] = len(customer90Seen) + } + if len(customerTotalSeen) > 0 { + row["customer_count_total"] = len(customerTotalSeen) + } + if floatFromMap(row, "sales_qty_90d") > 0 || floatFromMap(row, "sales_usd_90d") > 0 { + if intFromMap(row, "market_count_90d") == 0 { + row["market_count_90d"] = 1 + } + if intFromMap(row, "customer_count_90d") == 0 { + row["customer_count_90d"] = 1 + } + } + if floatFromMap(row, "sales_qty_total") > 0 || floatFromMap(row, "sales_usd_total") > 0 { + if intFromMap(row, "market_count_total") == 0 { + row["market_count_total"] = maxInt(1, intFromMap(row, "market_count_90d")) + } + if intFromMap(row, "customer_count_total") == 0 { + row["customer_count_total"] = maxInt(1, intFromMap(row, "customer_count_90d")) + } + } +} + +func productPerformanceGroupRecommendation(row map[string]any, groupField string, count int) string { + groupField = strings.TrimSpace(groupField) + salesQty90 := floatFromMap(row, "sales_qty_90d") + salesUSD90 := floatFromMap(row, "sales_usd_90d") + stockQty := floatFromMap(row, "stock_qty") + marginCost := floatFromMap(row, "gross_margin_cost_90d") + if marginCost == 0 { + marginCost = floatFromMap(row, "gross_margin_90d") + } + switch groupField { + case "market_key": + if salesQty90 <= 0 && stockQty > 0 { + return "Bu piyasada son 90 gunde satis yok" + } + if marginCost < 0 { + return "Bu piyasada ciplak marj negatif" + } + return fmt.Sprintf("Piyasa satisi %.0f adet / %.0f USD", salesQty90, salesUSD90) + case "color_yaka": + if salesQty90 <= 0 && stockQty > 0 { + return "Renk/yaka stokta, son 90 gun satisi yok" + } + if marginCost < 0 { + return "Renk/yaka ciplak marji negatif" + } + return fmt.Sprintf("Renk/yaka satisi %.0f adet", salesQty90) + default: + if recommendation := strings.TrimSpace(stringFromMap(row, "recommendation")); recommendation != "" { + return recommendation + } + return fmt.Sprintf("%d satir", count) + } +} + func isProductPerformanceMarginField(field string) bool { return strings.HasPrefix(field, "gross_margin") } @@ -5300,12 +5774,15 @@ func productPerformanceSalesGroupScore(row map[string]any) float64 { } func productPerformanceSalesPeriodScore(row map[string]any, suffix string) float64 { + salesUSD := floatFromMap(row, "sales_usd_"+suffix) salesIndex := floatFromMap(row, "sales_index_"+suffix) + if salesIndex <= 0 && suffix == "total" { + salesIndex = floatFromMap(row, "sales_index_total") + } margin := floatFromMap(row, "gross_margin_cost_"+suffix) if margin == 0 { margin = floatFromMap(row, "gross_margin_"+suffix) } - invoiceCount := floatFromMap(row, "invoice_count_"+suffix) salesQty := floatFromMap(row, "sales_qty_"+suffix) stockTurnover := floatFromMap(row, "stock_turnover_"+suffix) if stockTurnover == 0 { @@ -5313,12 +5790,15 @@ func productPerformanceSalesPeriodScore(row map[string]any, suffix string) float stockTurnover = salesQty / stockQty } } - score := minFloat(35, maxFloat(0, salesIndex)*18) + - minFloat(30, maxFloat(0, margin)*60) + - minFloat(15, invoiceCount*1.5) + - minFloat(10, salesQty/10) + - minFloat(10, maxFloat(0, stockTurnover)*5) - return score + return productPerformanceProductScore( + suffix, + salesUSD, + salesIndex, + margin, + stockTurnover, + productPerformancePeriodCount(row, "market_count", suffix), + productPerformancePeriodCount(row, "customer_count", suffix), + ) } func productPerformanceCustomerSalesGroupScore(row map[string]any) float64 { @@ -5346,24 +5826,139 @@ func productPerformanceCustomerSalesGroupScore(row map[string]any) float64 { func productPerformanceCustomerSalesPeriodScore(row map[string]any) float64 { salesUSD := floatFromMap(row, "sales_usd") margin := floatFromMap(row, "gross_margin_cost") - invoiceCount := floatFromMap(row, "invoice_count") + if margin == 0 { + margin = floatFromMap(row, "gross_margin") + } salesQty := floatFromMap(row, "sales_qty") productCount := floatFromMap(row, "product_count") - return minFloat(35, salesUSD/1000) + - minFloat(25, maxFloat(0, margin)*55) + - minFloat(20, invoiceCount*2) + - minFloat(10, salesQty/10) + - minFloat(10, productCount) + if groupCount := floatFromMap(row, "product_group_count"); groupCount > 0 { + productCount = groupCount + } + suffix := strings.TrimSpace(stringFromMap(row, "suffix")) + if suffix == "" { + suffix = "90d" + } + return productPerformanceCustomerScore(suffix, salesUSD, margin, productCount, salesQty) +} + +func productPerformanceProductScore(suffix string, salesUSD, salesIndex, margin, stockTurnover, marketCount, customerCount float64) float64 { + revenueScore := productPerformanceRevenueScore(suffix, salesUSD, salesIndex, productPerformanceProductRevenueTarget(suffix)) + score := 0.30*productPerformanceMarginComponentScore(margin) + + 0.20*productPerformanceRatioScore(stockTurnover, 1.50) + + 0.20*revenueScore + + 0.15*productPerformanceRatioScore(marketCount, 8) + + 0.15*productPerformanceRatioScore(customerCount, 25) + return productPerformanceRoundScore(score) +} + +func productPerformanceCustomerScore(suffix string, salesUSD, margin, productGroupCount, salesQty float64) float64 { + score := 0.35*productPerformanceRatioScore(salesUSD, productPerformanceCustomerRevenueTarget(suffix)) + + 0.30*productPerformanceMarginComponentScore(margin) + + 0.20*productPerformanceRatioScore(productGroupCount, 8) + + 0.15*productPerformanceRatioScore(salesQty, productPerformanceCustomerQtyTarget(suffix)) + return productPerformanceRoundScore(score) +} + +func productPerformanceRevenueScore(suffix string, salesUSD, salesIndex, absoluteTarget float64) float64 { + if salesIndex > 0 { + return productPerformanceRatioScore(salesIndex, 2) + } + return productPerformanceRatioScore(salesUSD, absoluteTarget) +} + +func productPerformanceRelativeIndex(value, average float64) float64 { + if value <= 0 || average <= 0 { + return 0 + } + return value / average +} + +func productPerformanceMarginComponentScore(margin float64) float64 { + return productPerformanceRatioScore(maxFloat(0, margin), 0.40) +} + +func productPerformanceRatioScore(value, target float64) float64 { + if target <= 0 || value <= 0 { + return 0 + } + return minFloat(100, maxFloat(0, value)*100/target) +} + +func productPerformanceRoundScore(score float64) float64 { + if score < 0 { + score = 0 + } + if score > 100 { + score = 100 + } + return math.Round(score*10000) / 10000 +} + +func productPerformanceProductRevenueTarget(suffix string) float64 { + switch suffix { + case "180d": + return 20000 + case "365d": + return 40000 + case "total": + return 120000 + default: + return 10000 + } +} + +func productPerformanceCustomerRevenueTarget(suffix string) float64 { + switch suffix { + case "180d": + return 50000 + case "365d": + return 100000 + case "total": + return 300000 + default: + return 25000 + } +} + +func productPerformanceCustomerQtyTarget(suffix string) float64 { + switch suffix { + case "180d": + return 1000 + case "365d": + return 2000 + case "total": + return 6000 + default: + return 500 + } +} + +func productPerformancePeriodCount(row map[string]any, prefix, suffix string) float64 { + if value, ok := row[prefix+"_"+suffix]; ok { + if n := floatFromAny(value); n > 0 { + return n + } + } + if suffix == "total" { + if value, ok := row[prefix+"_total"]; ok { + return floatFromAny(value) + } + } + if value, ok := row[prefix+"_90d"]; ok { + return floatFromAny(value) + } + return floatFromAny(row[prefix]) } func rowPeriodMetricMap(row map[string]any, suffix string) map[string]any { return map[string]any{ - "sales_usd": floatFromMap(row, "sales_usd_"+suffix), - "gross_margin_cost": floatFromMap(row, "gross_margin_cost_"+suffix), - "gross_margin": floatFromMap(row, "gross_margin_"+suffix), - "invoice_count": floatFromMap(row, "invoice_count_"+suffix), - "sales_qty": floatFromMap(row, "sales_qty_"+suffix), - "product_count": floatFromMap(row, "product_count"), + "suffix": suffix, + "sales_usd": floatFromMap(row, "sales_usd_"+suffix), + "gross_margin_cost": floatFromMap(row, "gross_margin_cost_"+suffix), + "gross_margin": floatFromMap(row, "gross_margin_"+suffix), + "sales_qty": floatFromMap(row, "sales_qty_"+suffix), + "product_group_count": productPerformancePeriodCount(row, "product_group_count", suffix), + "product_count": floatFromMap(row, "product_count"), } } @@ -5608,9 +6203,7 @@ func cleanProductPerformanceFirstGroup(value string) string { if value == "-" { return "" } - normalized := strings.ToUpper(value) - normalized = strings.NewReplacer("İ", "I", "Ş", "S", "Ğ", "G", "Ü", "U", "Ö", "O", "Ç", "C").Replace(normalized) - switch normalized { + switch normalizeProductPerformanceTurkishText(value) { case "YETISKIN", "YETISKIN/GARSON", "GARSON": return "" default: @@ -5618,6 +6211,24 @@ func cleanProductPerformanceFirstGroup(value string) string { } } +func normalizeProductPerformanceTurkishText(value string) string { + value = strings.ToUpper(strings.TrimSpace(value)) + return strings.NewReplacer( + "\u0130", "I", + "\u015E", "S", + "\u011E", "G", + "\u00DC", "U", + "\u00D6", "O", + "\u00C7", "C", + "\u0131", "I", + "\u015F", "S", + "\u011F", "G", + "\u00FC", "U", + "\u00F6", "O", + "\u00E7", "C", + ).Replace(value) +} + func cleanProductPerformanceOptionalAttr(value string) string { value = strings.TrimSpace(value) if value == "-" { @@ -5659,6 +6270,13 @@ func minInt(a, b int) int { return b } +func maxInt(a, b int) int { + if a > b { + return a + } + return b +} + func ListProductPerformanceSalesDetails(ctx context.Context, pg *sql.DB, productCode, colorCode, yakaKodu string, limit int) ([]models.ProductPerformanceSalesDetailRow, error) { if limit <= 0 || limit > 500 { limit = 100 @@ -5679,7 +6297,7 @@ FROM mk_product_performance_sales_daily WHERE product_code=$1 AND color_code=$2 AND yaka_kodu=$3 - AND upper(btrim(COALESCE(urun_ilk_grubu,''))) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'DIGER') + AND upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER') ORDER BY sales_usd DESC, sales_date DESC LIMIT $4 `, strings.TrimSpace(productCode), strings.TrimSpace(colorCode), strings.TrimSpace(yakaKodu), limit) @@ -5936,15 +6554,24 @@ func normalizeProductPerformanceCostPair(costPriceUSD, basePriceUSD float64) (fl return costPriceUSD, basePriceUSD } -const productPerformanceExcludedFirstGroupsSQL = "('MALZEMELI FASON', 'MALZEMESIZ FASON', 'DIGER')" +const productPerformanceTurkishTranslateSQL = "U&'\\0130\\015E\\011E\\00DC\\00D6\\00C7\\0131\\015F\\011F\\00FC\\00F6\\00E7', 'ISGUOCisguoc'" +const productPerformanceExcludedFirstGroupsSQL = "('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER')" + +func productPerformanceNormalizedTextSQL(expr string) string { + return "upper(translate(btrim(COALESCE(" + expr + ",'')), " + productPerformanceTurkishTranslateSQL + "))" +} + +func productPerformanceCleanFirstGroupSQL(expr string) string { + return "CASE WHEN btrim(COALESCE(" + expr + ",'')) = '-' THEN '' WHEN " + productPerformanceNormalizedTextSQL(expr) + " IN ('YETISKIN', 'YETISKIN/GARSON', 'GARSON') THEN '' ELSE COALESCE(" + expr + ",'') END" +} func productPerformanceAllowedFirstGroupSQL(expr string) string { - return "upper(btrim(COALESCE(" + expr + ",''))) NOT IN " + productPerformanceExcludedFirstGroupsSQL + return productPerformanceNormalizedTextSQL(expr) + " NOT IN " + productPerformanceExcludedFirstGroupsSQL } func isExcludedProductPerformanceFirstGroup(value string) bool { - switch strings.ToUpper(strings.TrimSpace(value)) { - case "MALZEMELI FASON", "MALZEMESIZ FASON", "DIGER": + switch normalizeProductPerformanceTurkishText(value) { + case "MALZEMELI FASON", "MALZEMESIZ FASON", "MAZLEMELI FASON", "MAZEMESIZ FASON", "DIGER": return true default: return false @@ -6013,7 +6640,7 @@ SELECT DISTINCT ON (product_code, color_code, yaka_kodu) FROM mk_product_performance_kpi_daily WHERE kpi_date = (SELECT kpi_date FROM Latest) AND product_code || '|' || color_code || '|' || yaka_kodu = ANY($1) - AND upper(btrim(COALESCE(urun_ilk_grubu,''))) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'DIGER') + AND upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER') ORDER BY product_code, color_code, yaka_kodu, performance_score DESC `, pq.Array(variantKeys)) if err != nil { @@ -6069,68 +6696,68 @@ GROUP BY product_code, color_code, yaka_kodu func productPerformanceOrderRecommendation(row models.ProductPerformanceOrderAnalysisRow) (string, string) { switch { case row.OrderQty > 0 && row.NetStockAfterOrder < 0 && row.ExpectedProfitCostUSD >= 0: - return "STOKSUZ_TALEP", "Açık sipariş stoktan büyük. Karlı talep var; üretim/satın alma önceliği ver." + return "STOKSUZ_TALEP", "Acik siparis stoktan buyuk. Karli talep var; uretim/satin alma onceligi ver." case row.ExpectedProfitCostUSD < 0: - return "FIYAT_BASKISI", "Açık sipariş çıplak maliyete göre zarar yazıyor. Fiyat/maliyet kontrol edilmeli." + return "FIYAT_BASKISI", "Acik siparis ciplak maliyete gore zarar yaziyor. Fiyat/maliyet kontrol edilmeli." case row.ExpectedMarginCost >= 0.25 && row.NetStockAfterOrder >= 0: - return "YILDIZ_URUN", "Açık sipariş karlı ve stok karşılıyor. Teslimat korunmalı." + return "YILDIZ_URUN", "Acik siparis karli ve stok karsiliyor. Teslimat korunmali." case row.ExpectedMarginCost >= 0.25 && row.NetStockAfterOrder < 0: - return "FIYAT_FIRSATI", "Karlı talep var ama stok yetersiz. Üretim planına alınmalı." + return "FIYAT_FIRSATI", "Karli talep var ama stok yetersiz. Uretim planina alinmali." case row.OverdueQty > 0: - return "TAKIP", "Termin gecikmesi olan açık sipariş var. Operasyon takibi gerekli." + return "TAKIP", "Termin gecikmesi olan acik siparis var. Operasyon takibi gerekli." default: - return "TAKIP", "Sipariş, stok ve fiyat düzenli izlenmeli." + return "TAKIP", "Siparis, stok ve fiyat duzenli izlenmeli." } } func productPerformanceOrderGroupRecommendation(row models.ProductPerformanceOrderGroupRow) (string, string) { switch { case row.ExpectedProfitCostUSD < 0: - return "FIYAT_BASKISI", "Çıplak maliyete göre zarar yazan açık sipariş var. Fiyat/maliyet acil kontrol edilmeli." + return "FIYAT_BASKISI", "Ciplak maliyete gore zarar yazan acik siparis var. Fiyat/maliyet acil kontrol edilmeli." case row.ExpectedProfitBaseUSD < 0: - return "FIYAT_BASKISI", "Taban maliyete göre brüt zarar var. Satış fiyatı veya iskonto kontrol edilmeli." + return "FIYAT_BASKISI", "Taban maliyete gore brut zarar var. Satis fiyati veya iskonto kontrol edilmeli." case row.NetStockAfterOrder < 0 && row.ExpectedProfitCostUSD >= 0: - return "STOKSUZ_TALEP", "Karlı açık talep var ama stok yetersiz. Üretim/satın alma önceliği ver." + return "STOKSUZ_TALEP", "Karli acik talep var ama stok yetersiz. Uretim/satin alma onceligi ver." case row.ExpectedMarginCost >= 0.25 && row.ExpectedMarginBase >= 0.15: - return "YILDIZ_URUN", "Piyasa/müşteri açık siparişleri karlı. Teslimat ve stok korunmalı." + return "YILDIZ_URUN", "Piyasa/musteri acik siparisleri karli. Teslimat ve stok korunmali." case row.OverdueQty > 0: - return "TAKIP", "Geciken açık sipariş var. Operasyon takibi gerekli." + return "TAKIP", "Geciken acik siparis var. Operasyon takibi gerekli." default: - return "TAKIP", "Sipariş karlılığı ve stok yeterliliği izlenmeli." + return "TAKIP", "Siparis karliligi ve stok yeterliligi izlenmeli." } } func productPerformanceOrderProductCustomerRecommendation(row models.ProductPerformanceOrderProductCustomerRow) (string, string) { switch { case row.ExpectedProfitCostUSD < 0: - return "FIYAT_BASKISI", "Bu müşteri/ürün açık siparişi çıplak maliyete göre zarar yazıyor." + return "FIYAT_BASKISI", "Bu musteri/urun acik siparisi ciplak maliyete gore zarar yaziyor." case row.ExpectedProfitBaseUSD < 0: - return "FIYAT_BASKISI", "Bu müşteri/ürün açık siparişi taban maliyete göre brüt zarar yazıyor." + return "FIYAT_BASKISI", "Bu musteri/urun acik siparisi taban maliyete gore brut zarar yaziyor." case row.NetStockAfterOrder < 0 && row.ExpectedProfitCostUSD >= 0: - return "STOKSUZ_TALEP", "Müşteride karlı talep var ama stok yetersiz." + return "STOKSUZ_TALEP", "Musteride karli talep var ama stok yetersiz." case row.ExpectedMarginCost >= 0.25 && row.ExpectedMarginBase >= 0.15: - return "YILDIZ_URUN", "Müşteri bazında karlı açık talep var." + return "YILDIZ_URUN", "Musteri bazinda karli acik talep var." case row.OverdueQty > 0: - return "TAKIP", "Bu müşteri/ürün kırılımında geciken açık sipariş var." + return "TAKIP", "Bu musteri/urun kiriliminda geciken acik siparis var." default: - return "TAKIP", "Müşteri talebi, fiyat ve stok birlikte izlenmeli." + return "TAKIP", "Musteri talebi, fiyat ve stok birlikte izlenmeli." } } func productPerformanceOrderMarketDetailRecommendation(row models.ProductPerformanceOrderMarketDetailRow) (string, string) { switch { case row.ExpectedProfitCostUSD < 0: - return "FIYAT_BASKISI", "Sipariş satırı çıplak maliyete göre zarar yazıyor." + return "FIYAT_BASKISI", "Siparis satiri ciplak maliyete gore zarar yaziyor." case row.ExpectedProfitBaseUSD < 0: - return "FIYAT_BASKISI", "Sipariş satırı taban maliyete göre brüt zarar yazıyor." + return "FIYAT_BASKISI", "Siparis satiri taban maliyete gore brut zarar yaziyor." case row.NetStockAfterOrder < 0 && row.ExpectedProfitCostUSD >= 0: - return "STOKSUZ_TALEP", "Karlı sipariş var ama mevcut stok siparişi karşılamıyor." + return "STOKSUZ_TALEP", "Karli siparis var ama mevcut stok siparisi karsilamiyor." case row.IsOverdue: - return "TAKIP", "Termin geçmiş; teslimat aksiyonu gerekli." + return "TAKIP", "Termin gecmis; teslimat aksiyonu gerekli." case row.ExpectedMarginCost >= 0.25: - return "YILDIZ_URUN", "Sipariş karlı; teslimat ve stok korunmalı." + return "YILDIZ_URUN", "Siparis karli; teslimat ve stok korunmali." default: - return "TAKIP", "Sipariş fiyatı, stok ve termin izlenmeli." + return "TAKIP", "Siparis fiyati, stok ve termin izlenmeli." } } diff --git a/svc/queries/product_performance_sql.go b/svc/queries/product_performance_sql.go index b91c720..16a9827 100644 --- a/svc/queries/product_performance_sql.go +++ b/svc/queries/product_performance_sql.go @@ -455,7 +455,7 @@ SalesAgg AS ( FROM mk_product_performance_sales_daily WHERE sales_date >= DATE '2022-01-01' AND sales_date <= $1::date - AND upper(btrim(COALESCE(urun_ilk_grubu,''))) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'DIGER') + AND upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER') GROUP BY product_code, color_code, yaka_kodu, market_key ), Scope AS ( @@ -497,7 +497,7 @@ Scope AS ( FROM LatestStock ls LEFT JOIN mk_product_performance_price_dim pd ON pd.product_code=ls.product_code WHERE COALESCE(ls.stock_qty, 0) <> 0 - AND upper(btrim(COALESCE(pd.urun_ilk_grubu,''))) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'DIGER') + AND upper(translate(btrim(COALESCE(pd.urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER') AND NOT EXISTS ( SELECT 1 FROM SalesAgg sa @@ -559,7 +559,7 @@ Base AS ( LEFT JOIN Stock365Start s365 ON s365.product_code=s.product_code AND s365.color_code=s.color_code AND s365.yaka_kodu=s.yaka_kodu LEFT JOIN StockTotalStart stotal ON stotal.product_code=s.product_code AND stotal.color_code=s.color_code AND stotal.yaka_kodu=s.yaka_kodu LEFT JOIN PriceDim pd ON pd.product_code=s.product_code - WHERE upper(btrim(COALESCE(NULLIF(s.urun_ilk_grubu,''), pd.urun_ilk_grubu, ''))) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'DIGER') + WHERE upper(translate(btrim(COALESCE(NULLIF(s.urun_ilk_grubu,''), pd.urun_ilk_grubu, '')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER') ), Spread AS ( SELECT @@ -638,12 +638,12 @@ SELECT ELSE 'TAKIP' END AS performance_bucket, CASE - WHEN COALESCE(sales_index_90d,0) >= 1.25 AND COALESCE(gross_margin_90d,0) >= 0.25 THEN 'Piyasa üstü satış ve iyi marj: stok ve fiyat gücü takip edilmeli.' - WHEN COALESCE(stock_qty,0) <= 0 AND COALESCE(sales_qty_90d,0) > 0 THEN 'Talep var, stok yok: üretim/satın alma planına alınmalı.' - WHEN COALESCE(stock_days_90d,0) > 180 AND COALESCE(sales_index_90d,0) < 0.75 THEN 'Stok yüksek, satış piyasa altı: kampanya veya fiyat kontrolü gerekli.' - WHEN COALESCE(price_index_90d,0) > 1.10 AND COALESCE(sales_index_90d,0) < 0.80 THEN 'Fiyat piyasa üstünde ve satış zayıf: fiyat revizyonu değerlendirilmeli.' - WHEN COALESCE(price_index_90d,0) < 0.90 AND COALESCE(sales_index_90d,0) > 1.00 THEN 'Satış güçlü, fiyat piyasa altı: taban fiyat artışı değerlendirilebilir.' - ELSE 'Düzenli takip.' + WHEN COALESCE(sales_index_90d,0) >= 1.25 AND COALESCE(gross_margin_90d,0) >= 0.25 THEN 'Piyasa ustu satis ve iyi marj: stok ve fiyat gucu takip edilmeli.' + WHEN COALESCE(stock_qty,0) <= 0 AND COALESCE(sales_qty_90d,0) > 0 THEN 'Talep var, stok yok: uretim/satin alma planina alinmali.' + WHEN COALESCE(stock_days_90d,0) > 180 AND COALESCE(sales_index_90d,0) < 0.75 THEN 'Stok yuksek, satis piyasa alti: kampanya veya fiyat kontrolu gerekli.' + WHEN COALESCE(price_index_90d,0) > 1.10 AND COALESCE(sales_index_90d,0) < 0.80 THEN 'Fiyat piyasa ustunde ve satis zayif: fiyat revizyonu degerlendirilmeli.' + WHEN COALESCE(price_index_90d,0) < 0.90 AND COALESCE(sales_index_90d,0) > 1.00 THEN 'Satis guclu, fiyat piyasa alti: taban fiyat artisi degerlendirilebilir.' + ELSE 'Duzenli takip.' END AS recommendation, last_sale_date, COALESCE(last_ref_number, ''), diff --git a/svc/routes/product_images.go b/svc/routes/product_images.go index de340f2..9058e51 100644 --- a/svc/routes/product_images.go +++ b/svc/routes/product_images.go @@ -593,6 +593,23 @@ func resolveStoragePath(storagePath string) (string, []string) { filepath.Join("..", "..", relImageThumb), } + if u := extractImageUUID(raw, baseName); u != "" { + uFull := filepath.FromSlash(filepath.Join("uploads", "image", u+".jpg")) + uThumb := filepath.FromSlash(filepath.Join("uploads", "image", "t300", u+".jpg")) + candidates = append(candidates, + filepath.FromSlash(filepath.Join("image", u+".jpg")), + filepath.FromSlash(filepath.Join("image", "t300", u+".jpg")), + uFull, + uThumb, + filepath.Join(".", uFull), + filepath.Join(".", uThumb), + filepath.Join("..", uFull), + filepath.Join("..", uThumb), + filepath.Join("..", "..", uFull), + filepath.Join("..", "..", uThumb), + ) + } + if root := strings.TrimSpace(os.Getenv("BLOB_ROOT")); root != "" { candidates = append(candidates, filepath.Join(root, raw), @@ -601,6 +618,14 @@ func resolveStoragePath(storagePath string) (string, []string) { filepath.Join(root, relImage), filepath.Join(root, relImageThumb), ) + if u := extractImageUUID(raw, baseName); u != "" { + candidates = append(candidates, + filepath.Join(root, "uploads", "image", u+".jpg"), + filepath.Join(root, "uploads", "image", "t300", u+".jpg"), + filepath.Join(root, "image", u+".jpg"), + filepath.Join(root, "image", "t300", u+".jpg"), + ) + } } for _, p := range candidates { diff --git a/svc/routes/product_performance.go b/svc/routes/product_performance.go index 1c4c2c6..f37ef12 100644 --- a/svc/routes/product_performance.go +++ b/svc/routes/product_performance.go @@ -359,6 +359,64 @@ func GetProductPerformanceGroupedHandler(pg *sql.DB) http.HandlerFunc { } } +func GetProductPerformanceGroupedFilterOptionsHandler(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, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 60*time.Second) + defer cancel() + + mode := strings.TrimSpace(r.URL.Query().Get("mode")) + mainGroup := strings.TrimSpace(r.URL.Query().Get("urun_ana_grubu")) + groupLevels := splitCSVQuery(r.URL.Query().Get("group_levels")) + fields := splitCSVQuery(r.URL.Query().Get("fields")) + limit := intQuery(r, "limit", 5000) + + if r.Method == http.MethodPost { + var body struct { + Mode string `json:"mode"` + GroupLevels []string `json:"group_levels"` + MainGroup string `json:"urun_ana_grubu"` + Fields []string `json:"fields"` + Limit int `json:"limit"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "gecersiz filtre secenek istegi: "+err.Error(), http.StatusBadRequest) + return + } + if strings.TrimSpace(body.Mode) != "" { + mode = strings.TrimSpace(body.Mode) + } + if strings.TrimSpace(body.MainGroup) != "" { + mainGroup = strings.TrimSpace(body.MainGroup) + } + if len(body.GroupLevels) > 0 { + groupLevels = body.GroupLevels + } + if len(body.Fields) > 0 { + fields = body.Fields + } + if body.Limit > 0 { + limit = body.Limit + } + } + + options, err := queries.ListProductPerformanceGroupedFilterOptions(ctx, pg, queries.ProductPerformanceGroupedFilterOptionsRequest{ + Mode: mode, + GroupLevels: groupLevels, + MainGroup: mainGroup, + Fields: fields, + Limit: limit, + }) + if err != nil { + log.Printf("[ProductPerformance][grouped-filter-options] failed trace_id=%s mode=%s levels=%v fields=%d: %v", traceID, mode, groupLevels, len(fields), err) + http.Error(w, "urun performans filtre secenekleri alinamadi: "+err.Error(), http.StatusInternalServerError) + return + } + _ = json.NewEncoder(w).Encode(options) + } +} + func splitCSVQuery(value string) []string { parts := strings.Split(value, ",") out := make([]string, 0, len(parts)) diff --git a/ui/src/pages/ProductPerformanceProfitability.vue b/ui/src/pages/ProductPerformanceProfitability.vue index c9ac1a4..b5339c5 100644 --- a/ui/src/pages/ProductPerformanceProfitability.vue +++ b/ui/src/pages/ProductPerformanceProfitability.vue @@ -1,4 +1,4 @@ -