diff --git a/svc/main.go b/svc/main.go index de0ffb7..acfa5e4 100644 --- a/svc/main.go +++ b/svc/main.go @@ -994,6 +994,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", "POST", + "pricing", "view", + wrapV3(routes.GetProductPerformanceGroupedHandler(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 c14d812..d890150 100644 --- a/svc/models/product_performance.go +++ b/svc/models/product_performance.go @@ -179,6 +179,11 @@ type ProductPerformanceOrderProductCustomerRow struct { ColorCode string `json:"color_code"` 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"` CustomerCode string `json:"customer_code"` CustomerName string `json:"customer_name"` @@ -211,6 +216,11 @@ type ProductPerformanceOrderMarketDetailRow struct { ColorCode string `json:"color_code"` 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"` OrderQty float64 `json:"order_qty"` OrderUSD float64 `json:"order_usd"` AvgOrderPriceUSD float64 `json:"avg_order_price_usd"` @@ -288,6 +298,11 @@ type ProductPerformanceSalesBreakdownRow struct { ColorCode string `json:"color_code"` 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"` @@ -325,6 +340,8 @@ type ProductPerformanceSalesBreakdownRow struct { 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"` @@ -336,8 +353,17 @@ type ProductPerformanceSalesBreakdownRow struct { 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"` diff --git a/svc/queries/product_performance.go b/svc/queries/product_performance.go index db172e8..cd1b477 100644 --- a/svc/queries/product_performance.go +++ b/svc/queries/product_performance.go @@ -1073,8 +1073,10 @@ func ListProductPerformanceGeneral(ctx context.Context, pg *sql.DB, limit int) ( if err := EnsureProductPerformanceTables(pg); err != nil { return nil, err } - if limit <= 0 || limit > 1000 { + if limit <= 0 { limit = 500 + } else if limit > 50000 { + limit = 50000 } rows, err := pg.QueryContext(ctx, ` WITH Bounds AS ( @@ -1350,8 +1352,10 @@ func ListProductPerformanceOrderAnalysis(ctx context.Context, pg *sql.DB, limit if err := EnsureProductPerformanceTables(pg); err != nil { return nil, err } - if limit <= 0 || limit > 1000 { + if limit <= 0 { limit = 500 + } else if limit > 50000 { + limit = 50000 } rows, err := db.MssqlDB.QueryContext(ctx, ` @@ -1592,9 +1596,22 @@ ORDER BY SUM(o.AmountUSD) DESC, SUM(o.Qty) DESC, o.ProductCode, o.ColorCode, o.Y if err != nil { return nil, err } + attrByKey, err := productPerformanceAttrLookup(ctx, pg, stockKeys) + if err != nil { + return nil, err + } for i := range out { row := &out[i] price := priceByProduct[row.ProductCode] + attr := attrByKey[productPerformanceVariantKey(row.ProductCode, row.ColorCode, row.YakaKodu)] + if strings.TrimSpace(row.ItemDescription) == "" { + row.ItemDescription = attr.itemDescription + } + row.Kategori = attr.kategori + row.AskiliYan = attr.askiliYan + row.UrunIlkGrubu = attr.urunIlkGrubu + row.UrunAnaGrubu = attr.urunAnaGrubu + row.UrunAltGrubu = attr.urunAltGrubu row.CostPriceUSD = price.cost row.BasePriceUSD = price.base row.StockQty = stockByKey[productPerformanceVariantKey(row.ProductCode, row.ColorCode, row.YakaKodu)] @@ -1952,9 +1969,22 @@ ORDER BY SUM(AmountUSD) DESC, SUM(Qty) DESC if err != nil { return nil, err } + attrByKey, err := productPerformanceAttrLookup(ctx, pg, stockKeys) + if err != nil { + return nil, err + } for i := range out { row := &out[i] price := priceByProduct[row.ProductCode] + attr := attrByKey[productPerformanceVariantKey(row.ProductCode, row.ColorCode, row.YakaKodu)] + if strings.TrimSpace(row.ItemDescription) == "" { + row.ItemDescription = attr.itemDescription + } + row.Kategori = attr.kategori + row.AskiliYan = attr.askiliYan + row.UrunIlkGrubu = attr.urunIlkGrubu + row.UrunAnaGrubu = attr.urunAnaGrubu + row.UrunAltGrubu = attr.urunAltGrubu row.CostPriceUSD = price.cost row.BasePriceUSD = price.base row.StockQty = stockByKey[productPerformanceVariantKey(row.ProductCode, row.ColorCode, row.YakaKodu)] @@ -1977,8 +2007,10 @@ func ListProductPerformanceOrderMarketDetails(ctx context.Context, pg *sql.DB, l if err := EnsureProductPerformanceTables(pg); err != nil { return nil, err } - if limit <= 0 || limit > 1500 { + if limit <= 0 { limit = 800 + } else if limit > 50000 { + limit = 50000 } rows, err := db.MssqlDB.QueryContext(ctx, ` WITH OpenOrderLines AS ( @@ -2268,8 +2300,10 @@ func ListProductPerformanceSalesBreakdown(ctx context.Context, pg *sql.DB, break if err := EnsureProductPerformanceTables(pg); err != nil { return nil, err } - if limit <= 0 || limit > 1000 { + if limit <= 0 { limit = 500 + } else if limit > 50000 { + limit = 50000 } mode := strings.ToLower(strings.TrimSpace(breakdown)) fields := map[string]string{ @@ -2277,6 +2311,11 @@ func ListProductPerformanceSalesBreakdown(ctx context.Context, pg *sql.DB, break "color_code": "s.color_code", "yaka_kodu": "s.yaka_kodu", "item_description": "MAX(s.item_description)", + "kategori": "MAX(s.kategori)", + "askili_yan": "MAX(s.askili_yan)", + "urun_ilk_grubu": "MAX(s.urun_ilk_grubu)", + "urun_ana_grubu": "MAX(s.urun_ana_grubu)", + "urun_alt_grubu": "MAX(s.urun_alt_grubu)", "market_key": "s.market_key", "country": "s.customer_country", "customer_segment": "s.customer_segment", @@ -2287,21 +2326,37 @@ func ListProductPerformanceSalesBreakdown(ctx context.Context, pg *sql.DB, break groupCols := []string{} switch mode { case "color_yaka_market_customer": + selected["product_code"] = true selected["color_code"] = true selected["yaka_kodu"] = true - selected["market_key"] = true - selected["customer_code"] = true - selected["customer_name"] = true - groupCols = []string{"s.color_code", "s.yaka_kodu", "s.market_key", "s.customer_code"} - case "product_country_segment_market_customer": - selected["product_code"] = true selected["item_description"] = true + selected["kategori"] = true + selected["askili_yan"] = true + selected["urun_ilk_grubu"] = true + selected["urun_ana_grubu"] = true + selected["urun_alt_grubu"] = true selected["country"] = true selected["customer_segment"] = true selected["market_key"] = true selected["customer_code"] = true selected["customer_name"] = true - groupCols = []string{"s.product_code", "s.customer_country", "s.customer_segment", "s.market_key", "s.customer_code"} + groupCols = []string{"s.color_code", "s.yaka_kodu", "s.askili_yan", "s.urun_ilk_grubu", "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 + selected["kategori"] = true + selected["askili_yan"] = true + selected["urun_ilk_grubu"] = true + selected["urun_ana_grubu"] = true + selected["urun_alt_grubu"] = true + selected["color_code"] = true + selected["yaka_kodu"] = true + selected["country"] = true + selected["customer_segment"] = true + selected["market_key"] = true + selected["customer_code"] = true + selected["customer_name"] = true + groupCols = []string{"s.market_key", "s.customer_code", "s.product_code", "s.color_code", "s.yaka_kodu", "s.customer_country", "s.customer_segment"} case "market_customer_product": selected["market_key"] = true selected["customer_code"] = true @@ -2310,6 +2365,11 @@ func ListProductPerformanceSalesBreakdown(ctx context.Context, pg *sql.DB, break selected["color_code"] = true selected["yaka_kodu"] = true selected["item_description"] = true + selected["kategori"] = true + selected["askili_yan"] = true + selected["urun_ilk_grubu"] = true + selected["urun_ana_grubu"] = true + selected["urun_alt_grubu"] = true groupCols = []string{"s.market_key", "s.customer_code", "s.product_code", "s.color_code", "s.yaka_kodu"} case "country_segment_market_customer_product": selected["country"] = true @@ -2321,15 +2381,29 @@ func ListProductPerformanceSalesBreakdown(ctx context.Context, pg *sql.DB, break selected["color_code"] = true selected["yaka_kodu"] = true selected["item_description"] = true + selected["kategori"] = true + selected["askili_yan"] = true + selected["urun_ilk_grubu"] = true + selected["urun_ana_grubu"] = true + selected["urun_alt_grubu"] = true groupCols = []string{"s.customer_country", "s.customer_segment", "s.market_key", "s.customer_code", "s.product_code", "s.color_code", "s.yaka_kodu"} default: mode = "color_yaka_market_customer" + selected["product_code"] = true selected["color_code"] = true selected["yaka_kodu"] = true + selected["item_description"] = true + selected["kategori"] = true + selected["askili_yan"] = true + selected["urun_ilk_grubu"] = true + selected["urun_ana_grubu"] = true + selected["urun_alt_grubu"] = true + selected["country"] = true + selected["customer_segment"] = true selected["market_key"] = true selected["customer_code"] = true selected["customer_name"] = true - groupCols = []string{"s.color_code", "s.yaka_kodu", "s.market_key", "s.customer_code"} + groupCols = []string{"s.color_code", "s.yaka_kodu", "s.askili_yan", "s.urun_ilk_grubu", "s.urun_ana_grubu", "s.urun_alt_grubu", "s.product_code", "s.customer_country", "s.market_key", "s.customer_segment", "s.customer_code"} } selectExpr := func(name string) string { @@ -2340,7 +2414,31 @@ func ListProductPerformanceSalesBreakdown(ctx context.Context, pg *sql.DB, break } query := fmt.Sprintf(` -WITH Agg AS ( +WITH LatestKPI AS ( + SELECT DISTINCT ON (product_code, color_code, yaka_kodu) + product_code, + color_code, + yaka_kodu, + kategori, + askili_yan, + COALESCE(NULLIF(urun_ilk_grubu,''), yas_grubu) AS urun_ilk_grubu, + COALESCE(NULLIF(urun_ana_grubu,''), seri) 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) + ORDER BY product_code, color_code, yaka_kodu, performance_score DESC +), +StockAgg AS ( + SELECT + product_code, + color_code, + yaka_kodu, + COALESCE(SUM(stock_qty),0) AS stock_qty + FROM mk_product_performance_stock_daily + WHERE stock_date = (SELECT MAX(stock_date) FROM mk_product_performance_stock_daily) + GROUP BY product_code, color_code, yaka_kodu +), +Agg AS ( SELECT $2::text AS breakdown, %s, @@ -2352,7 +2450,13 @@ WITH Agg AS ( %s, %s, %s, + %s, + %s, + %s, + %s, + %s, COUNT(DISTINCT product_code) AS product_count, + COALESCE(MAX(stock_qty),0) AS stock_qty, COUNT(DISTINCT customer_code) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days' AND COALESCE(customer_code,'') <> '')::integer AS customer_count_90d, COALESCE(SUM(invoice_count) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0)::integer AS invoice_count_90d, COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0) AS sales_qty_90d, @@ -2387,6 +2491,8 @@ WITH Agg AS ( END AS cost_price_usd_180d, COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '359 days'),0) AS sales_qty_365d, COALESCE(SUM(sales_usd) FILTER (WHERE sales_date >= current_date - INTERVAL '359 days'),0) AS sales_usd_365d, + COUNT(DISTINCT customer_code) FILTER (WHERE sales_date >= current_date - INTERVAL '359 days' AND COALESCE(customer_code,'') <> '')::integer AS customer_count_365d, + COALESCE(SUM(invoice_count) FILTER (WHERE sales_date >= current_date - INTERVAL '359 days'),0)::integer AS invoice_count_365d, CASE WHEN COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '359 days'),0)=0 THEN 0 ELSE COALESCE(SUM(sales_usd) FILTER (WHERE sales_date >= current_date - INTERVAL '359 days'),0) / NULLIF(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '359 days'),0) @@ -2419,10 +2525,18 @@ WITH Agg AS ( FROM ( SELECT s.*, + COALESCE(k.kategori,'') AS kategori, + COALESCE(k.askili_yan,'') AS askili_yan, + COALESCE(k.urun_ilk_grubu,'') AS urun_ilk_grubu, + COALESCE(k.urun_ana_grubu,'') AS urun_ana_grubu, + COALESCE(k.urun_alt_grubu,'') AS urun_alt_grubu, COALESCE(pd.base_price_usd,0) AS base_price_usd, - COALESCE(pd.cost_price_usd,0) AS cost_price_usd + COALESCE(pd.cost_price_usd,0) AS cost_price_usd, + COALESCE(st.stock_qty,0) AS stock_qty FROM mk_product_performance_sales_daily s LEFT JOIN mk_product_performance_price_dim pd ON pd.product_code = s.product_code + LEFT JOIN LatestKPI k ON k.product_code = s.product_code AND k.color_code = s.color_code AND k.yaka_kodu = s.yaka_kodu + LEFT JOIN StockAgg st ON st.product_code = s.product_code AND st.color_code = s.color_code AND st.yaka_kodu = s.yaka_kodu ) s WHERE sales_date >= DATE '2022-01-01' GROUP BY %s @@ -2463,12 +2577,18 @@ SELECT color_code, yaka_kodu, item_description, + kategori, + askili_yan, + urun_ilk_grubu, + urun_ana_grubu, + urun_alt_grubu, market_key, country, customer_segment, customer_code, customer_name, product_count, + stock_qty, customer_count_90d, invoice_count_90d, sales_qty_90d, @@ -2500,6 +2620,8 @@ SELECT gross_profit_cost_usd_365d, gross_margin_base_365d, gross_margin_cost_365d, + customer_count_365d, + invoice_count_365d, customer_count_total, invoice_count_total, sales_qty_total, @@ -2511,15 +2633,55 @@ SELECT gross_profit_cost_usd_total, gross_margin_base_total, gross_margin_cost_total, + CASE WHEN stock_qty > 0 THEN sales_qty_90d / NULLIF(stock_qty,0) ELSE 0 END AS stock_turnover_90d, + CASE WHEN stock_qty > 0 THEN sales_qty_180d / NULLIF(stock_qty,0) ELSE 0 END AS stock_turnover_180d, + CASE WHEN stock_qty > 0 THEN sales_qty_365d / NULLIF(stock_qty,0) ELSE 0 END AS stock_turnover_365d, + CASE WHEN stock_qty > 0 THEN sales_qty_total / NULLIF(stock_qty,0) ELSE 0 END AS stock_turnover_total, has_cost, sales_index_90d, + CASE WHEN NOT has_cost THEN 0 ELSE + ROUND(( + LEAST(35, sales_usd_90d / 1000) + + LEAST(25, GREATEST(gross_margin_cost_90d,0) * 55) + + LEAST(20, invoice_count_90d * 2) + + LEAST(10, sales_qty_90d / 10) + + LEAST(10, product_count) + )::numeric, 4) + END AS customer_score_90d, + CASE WHEN NOT has_cost THEN 0 ELSE + ROUND(( + LEAST(35, sales_usd_180d / 1000) + + LEAST(25, GREATEST(gross_margin_cost_180d,0) * 55) + + LEAST(20, invoice_count_180d * 2) + + LEAST(10, sales_qty_180d / 10) + + LEAST(10, product_count) + )::numeric, 4) + END AS customer_score_180d, + CASE WHEN NOT has_cost THEN 0 ELSE + ROUND(( + LEAST(35, sales_usd_365d / 1000) + + LEAST(25, GREATEST(gross_margin_cost_365d,0) * 55) + + LEAST(20, invoice_count_365d * 2) + + LEAST(10, sales_qty_365d / 10) + + LEAST(10, product_count) + )::numeric, 4) + END AS customer_score_365d, + CASE WHEN NOT has_cost THEN 0 ELSE + ROUND(( + LEAST(35, sales_usd_total / 1000) + + LEAST(25, GREATEST(gross_margin_cost_total,0) * 55) + + LEAST(20, invoice_count_total * 2) + + LEAST(10, sales_qty_total / 10) + + LEAST(10, product_count) + )::numeric, 4) + END AS customer_score_total, CASE WHEN NOT has_cost THEN 0 ELSE ROUND( LEAST(35, sales_index_90d * 18) + LEAST(30, GREATEST(gross_margin_cost_90d,0) * 60) + LEAST(15, invoice_count_90d * 1.5) + LEAST(10, sales_qty_90d / 10) - + CASE WHEN last_sale_date >= to_char(current_date - INTERVAL '30 days','YYYY-MM-DD') THEN 10 ELSE 0 END, + + LEAST(10, (CASE WHEN stock_qty > 0 THEN sales_qty_90d / NULLIF(stock_qty,0) ELSE 0 END) * 5), 4 ) END AS performance_score, @@ -2545,6 +2707,11 @@ LIMIT $1 selectExpr("color_code"), selectExpr("yaka_kodu"), selectExpr("item_description"), + selectExpr("kategori"), + selectExpr("askili_yan"), + selectExpr("urun_ilk_grubu"), + selectExpr("urun_ana_grubu"), + selectExpr("urun_alt_grubu"), selectExpr("market_key"), selectExpr("country"), selectExpr("customer_segment"), @@ -2563,18 +2730,20 @@ LIMIT $1 var r models.ProductPerformanceSalesBreakdownRow if err := rows.Scan( &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.CustomerCount90, &r.InvoiceCount90, &r.SalesQty90, &r.SalesUSD90, + &r.ProductCount, &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, &r.CostPriceUSD180, &r.GrossProfitBase180, &r.GrossProfitCost180, &r.GrossMarginBase180, &r.GrossMarginCost180, &r.SalesQty365, &r.SalesUSD365, &r.AvgPriceUSD365, &r.BasePriceUSD365, &r.CostPriceUSD365, &r.GrossProfitBase365, &r.GrossProfitCost365, &r.GrossMarginBase365, - &r.GrossMarginCost365, &r.CustomerCountTotal, &r.InvoiceCountTotal, &r.SalesQtyTotal, &r.SalesUSDTotal, + &r.GrossMarginCost365, &r.CustomerCount365, &r.InvoiceCount365, &r.CustomerCountTotal, &r.InvoiceCountTotal, &r.SalesQtyTotal, &r.SalesUSDTotal, &r.AvgPriceUSDTotal, &r.BasePriceUSDTotal, &r.CostPriceUSDTotal, &r.GrossProfitBaseTotal, - &r.GrossProfitCostTotal, &r.GrossMarginBaseTotal, &r.GrossMarginCostTotal, &r.HasCost, - &r.SalesIndex90, &r.PerformanceScore, + &r.GrossProfitCostTotal, &r.GrossMarginBaseTotal, &r.GrossMarginCostTotal, + &r.StockTurnover90, &r.StockTurnover180, &r.StockTurnover365, &r.StockTurnoverTotal, &r.HasCost, + &r.SalesIndex90, &r.CustomerScore90, &r.CustomerScore180, &r.CustomerScore365, &r.CustomerScoreTotal, &r.PerformanceScore, &r.PerformanceBucket, &r.Recommendation, &r.LastSaleDate, ); err != nil { return nil, err @@ -2883,7 +3052,7 @@ WITH Source AS ( func productPerformanceSQLGroupExpr(field string) (string, bool) { switch field { - case "kategori", "askili_yan", "urun_ilk_grubu", "urun_ana_grubu", "urun_alt_grubu", "product_code", "color_code", "yaka_kodu": + case "kategori", "askili_yan", "urun_ilk_grubu", "urun_ana_grubu", "urun_alt_grubu", "product_code", "item_description", "color_code", "yaka_kodu": return field, true case "market_key": return "btrim(regexp_replace(COALESCE(market_key,''), '^.*\\|', ''))", true @@ -2921,7 +3090,7 @@ func productPerformanceGroupedSourceRows(ctx context.Context, pg *sql.DB, mode s } return productPerformanceIdleSourceRows(structsToMaps(rows)), nil case "sales_color_yaka_market_customer", "sales_product_country_segment_market_customer", "sales_market_customer_product", "sales_country_segment_market_customer_product": - rows, err := ListProductPerformanceSalesBreakdown(ctx, pg, mode, limit) + rows, err := ListProductPerformanceSalesBreakdown(ctx, pg, productPerformanceSalesBreakdownMode(mode), limit) return structsToMaps(rows), err case "order_product_customers": rows, err := ListProductPerformanceOrderProductCustomers(ctx, pg, limit) @@ -2935,6 +3104,21 @@ func productPerformanceGroupedSourceRows(ctx context.Context, pg *sql.DB, mode s } } +func productPerformanceSalesBreakdownMode(mode string) string { + switch strings.TrimSpace(mode) { + case "sales_color_yaka_market_customer": + return "color_yaka_market_customer" + case "sales_product_country_segment_market_customer": + return "product_country_segment_market_customer" + case "sales_market_customer_product": + return "market_customer_product" + case "sales_country_segment_market_customer_product": + return "country_segment_market_customer_product" + default: + return mode + } +} + func productPerformanceIdleSourceRows(rows []map[string]any) []map[string]any { out := make([]map[string]any, 0, len(rows)) for _, row := range rows { @@ -2987,7 +3171,7 @@ func appendProductPerformanceGroupedRows(out *[]map[string]any, sourceRows []map } func makeProductPerformanceGroupedRow(key string, level int, field, value string, rows []map[string]any) map[string]any { - row := aggregateProductPerformanceRows(rows) + row := aggregateProductPerformanceRows(rows, field) row["__group"] = true row["row_key"] = "group|" + key row["key"] = key @@ -3006,7 +3190,7 @@ func makeProductPerformanceGroupedRow(key string, level int, field, value string return row } -func aggregateProductPerformanceRows(rows []map[string]any) map[string]any { +func aggregateProductPerformanceRows(rows []map[string]any, groupField string) map[string]any { out := map[string]any{} for _, row := range rows { for key, value := range row { @@ -3027,12 +3211,12 @@ func aggregateProductPerformanceRows(rows []map[string]any) map[string]any { } } } - deriveProductPerformanceGroupMetrics(out) + deriveProductPerformanceGroupMetrics(out, groupField) out["performance_bucket"] = dominantProductPerformanceValue(rows, "performance_bucket") return out } -func deriveProductPerformanceGroupMetrics(out map[string]any) { +func deriveProductPerformanceGroupMetrics(out map[string]any, groupField string) { for _, suffix := range []string{"90d", "180d", "365d", "total"} { sales := floatFromMap(out, "sales_usd_"+suffix) qty := floatFromMap(out, "sales_qty_"+suffix) @@ -3064,13 +3248,138 @@ func deriveProductPerformanceGroupMetrics(out map[string]any) { if orderQty > 0 { out["avg_order_price_usd"] = orderUSD / orderQty } + out["customer_score_90d"] = productPerformanceCustomerSalesPeriodScore(rowPeriodMetricMap(out, "90d")) + out["customer_score_180d"] = productPerformanceCustomerSalesPeriodScore(rowPeriodMetricMap(out, "180d")) + out["customer_score_365d"] = productPerformanceCustomerSalesPeriodScore(rowPeriodMetricMap(out, "365d")) + out["customer_score_total"] = productPerformanceCustomerSalesPeriodScore(rowPeriodMetricMap(out, "total")) + out["performance_score"] = productPerformanceGroupScore(out, groupField) +} + +func productPerformanceGroupScore(row map[string]any, groupField string) float64 { + if floatFromMap(row, "order_qty") > 0 || floatFromMap(row, "order_usd") > 0 { + return productPerformanceOrderGroupScore(row) + } + if isProductPerformanceCustomerGroup(groupField) { + return productPerformanceCustomerSalesGroupScore(row) + } + return productPerformanceSalesGroupScore(row) +} + +func isProductPerformanceCustomerGroup(groupField string) bool { + switch strings.TrimSpace(groupField) { + case "customer_code", "customer_name": + return true + default: + return false + } +} + +func productPerformanceSalesGroupScore(row map[string]any) float64 { + salesIndex := floatFromMap(row, "sales_index_90d") + margin := floatFromMap(row, "gross_margin_cost_90d") + if margin == 0 { + margin = floatFromMap(row, "gross_margin_90d") + } + invoiceCount := floatFromMap(row, "invoice_count_90d") + salesQty := floatFromMap(row, "sales_qty_90d") + stockTurnover := floatFromMap(row, "stock_turnover_90d") + if stockTurnover == 0 { + if stockQty := floatFromMap(row, "stock_qty"); stockQty > 0 { + 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 +} + +func productPerformanceCustomerSalesGroupScore(row map[string]any) float64 { + scores := []float64{ + floatFromMap(row, "customer_score_90d"), + floatFromMap(row, "customer_score_180d"), + floatFromMap(row, "customer_score_365d"), + floatFromMap(row, "customer_score_total"), + } + total := 0.0 + count := 0.0 + for _, score := range scores { + if score <= 0 { + continue + } + total += score + count++ + } + if count == 0 { + return 0 + } + return total / count +} + +func productPerformanceCustomerSalesPeriodScore(row map[string]any) float64 { + salesUSD := floatFromMap(row, "sales_usd") + margin := floatFromMap(row, "gross_margin_cost") + if margin == 0 { + margin = floatFromMap(row, "gross_margin") + } + invoiceCount := floatFromMap(row, "invoice_count") + 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) +} + +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"), + } +} + +func productPerformanceOrderGroupScore(row map[string]any) float64 { + orderUSD := floatFromMap(row, "order_usd") + margin := floatFromMap(row, "expected_margin_cost") + orderCount := floatFromMap(row, "order_count") + orderQty := floatFromMap(row, "order_qty") + netStockAfterOrder := floatFromMap(row, "net_stock_after_order") + score := minFloat(35, orderUSD/1000) + + minFloat(25, maxFloat(0, margin)*60) + + minFloat(15, orderCount*2) + + minFloat(15, orderQty/10) + if netStockAfterOrder >= 0 { + score += 10 + } + return score +} + +func minFloat(a, b float64) float64 { + if a < b { + return a + } + return b +} + +func maxFloat(a, b float64) float64 { + if a > b { + return a + } + return b } func sanitizeProductPerformanceGroupLevels(levels []string) []string { allowed := map[string]bool{ "kategori": true, "askili_yan": true, "urun_ilk_grubu": true, "urun_ana_grubu": true, "urun_alt_grubu": true, - "product_code": true, "color_code": true, "yaka_kodu": true, "market_key": true, "customer_code": true, - "country": true, "customer_segment": true, + "product_code": true, "item_description": true, "color_code": true, "yaka_kodu": true, "market_key": true, "customer_code": true, + "customer_name": true, "country": true, "customer_segment": true, } out := make([]string, 0, len(levels)) seen := map[string]bool{} @@ -3087,21 +3396,21 @@ func sanitizeProductPerformanceGroupLevels(levels []string) []string { func defaultProductPerformanceGroupLevels(mode string) []string { switch mode { case "sales_color_yaka_market_customer": - return []string{"color_code", "yaka_kodu", "market_key", "customer_code"} + return []string{"color_code", "yaka_kodu", "askili_yan", "urun_ilk_grubu", "urun_ana_grubu", "urun_alt_grubu", "product_code", "item_description", "country", "market_key", "customer_segment", "customer_code", "customer_name"} case "idle": - return []string{"kategori", "askili_yan", "urun_ilk_grubu", "urun_ana_grubu", "urun_alt_grubu", "product_code"} + return []string{"askili_yan", "urun_ilk_grubu", "urun_ana_grubu", "urun_alt_grubu", "product_code", "item_description", "color_code", "yaka_kodu"} case "sales_product_country_segment_market_customer": - return []string{"product_code", "country", "customer_segment", "market_key", "customer_code"} + return []string{"market_key", "customer_code", "customer_name", "kategori", "askili_yan", "urun_ilk_grubu", "urun_ana_grubu", "urun_alt_grubu", "product_code", "item_description", "color_code", "yaka_kodu"} case "sales_market_customer_product": - return []string{"market_key", "customer_code", "product_code", "color_code", "yaka_kodu"} + return []string{"market_key", "customer_code", "customer_name", "kategori", "askili_yan", "urun_ilk_grubu", "urun_ana_grubu", "urun_alt_grubu", "product_code", "item_description", "color_code", "yaka_kodu"} case "sales_country_segment_market_customer_product": - return []string{"country", "customer_segment", "market_key", "customer_code", "product_code", "color_code", "yaka_kodu"} + return []string{"market_key", "customer_code", "customer_name", "kategori", "askili_yan", "urun_ilk_grubu", "urun_ana_grubu", "urun_alt_grubu", "product_code", "item_description", "color_code", "yaka_kodu"} case "order_product_customers": - return []string{"product_code", "market_key", "customer_code"} + return []string{"market_key", "customer_code", "customer_name", "kategori", "askili_yan", "urun_ilk_grubu", "urun_ana_grubu", "urun_alt_grubu", "product_code", "item_description", "color_code", "yaka_kodu"} case "order_market_details": - return []string{"market_key", "customer_code", "product_code", "color_code", "yaka_kodu"} + return []string{"market_key", "customer_code", "customer_name", "kategori", "askili_yan", "urun_ilk_grubu", "urun_ana_grubu", "urun_alt_grubu", "product_code", "item_description", "color_code", "yaka_kodu"} default: - return []string{"kategori", "askili_yan", "urun_ilk_grubu", "urun_ana_grubu", "urun_alt_grubu", "market_key", "product_code"} + return []string{"kategori", "askili_yan", "urun_ilk_grubu", "urun_ana_grubu", "urun_alt_grubu", "product_code", "item_description", "color_code", "yaka_kodu", "market_key"} } } @@ -3525,6 +3834,15 @@ type productPerformancePriceInfo struct { base float64 } +type productPerformanceAttrInfo struct { + itemDescription string + kategori string + askiliYan string + urunIlkGrubu string + urunAnaGrubu string + urunAltGrubu string +} + func productPerformanceVariantKey(productCode, colorCode, yakaKodu string) string { return strings.TrimSpace(productCode) + "|" + strings.TrimSpace(colorCode) + "|" + strings.TrimSpace(yakaKodu) } @@ -3554,6 +3872,46 @@ WHERE product_code = ANY($1) return out, rows.Err() } +func productPerformanceAttrLookup(ctx context.Context, pg *sql.DB, variantKeys []string) (map[string]productPerformanceAttrInfo, error) { + out := make(map[string]productPerformanceAttrInfo, len(variantKeys)) + if len(variantKeys) == 0 { + return out, nil + } + rows, err := pg.QueryContext(ctx, ` +WITH Latest AS ( + SELECT MAX(kpi_date) AS kpi_date + FROM mk_product_performance_kpi_daily +) +SELECT DISTINCT ON (product_code, color_code, yaka_kodu) + product_code, + color_code, + yaka_kodu, + COALESCE(item_description,'') AS item_description, + COALESCE(kategori,'') AS kategori, + COALESCE(askili_yan,'') AS askili_yan, + COALESCE(NULLIF(urun_ilk_grubu,''), yas_grubu, '') AS urun_ilk_grubu, + COALESCE(NULLIF(urun_ana_grubu,''), seri, '') AS urun_ana_grubu, + COALESCE(urun_alt_grubu,'') AS urun_alt_grubu +FROM mk_product_performance_kpi_daily +WHERE kpi_date = (SELECT kpi_date FROM Latest) + AND product_code || '|' || color_code || '|' || yaka_kodu = ANY($1) +ORDER BY product_code, color_code, yaka_kodu, performance_score DESC +`, pq.Array(variantKeys)) + if err != nil { + return nil, err + } + defer rows.Close() + for rows.Next() { + var productCode, colorCode, yakaKodu string + var p productPerformanceAttrInfo + if err := rows.Scan(&productCode, &colorCode, &yakaKodu, &p.itemDescription, &p.kategori, &p.askiliYan, &p.urunIlkGrubu, &p.urunAnaGrubu, &p.urunAltGrubu); err != nil { + return nil, err + } + out[productPerformanceVariantKey(productCode, colorCode, yakaKodu)] = p + } + return out, rows.Err() +} + func productPerformanceStockLookup(ctx context.Context, pg *sql.DB, variantKeys []string) (map[string]float64, error) { out := make(map[string]float64, len(variantKeys)) if len(variantKeys) == 0 { diff --git a/svc/routes/product_performance.go b/svc/routes/product_performance.go index a3694c9..cf86eda 100644 --- a/svc/routes/product_performance.go +++ b/svc/routes/product_performance.go @@ -268,19 +268,50 @@ func GetProductPerformanceGroupedHandler(pg *sql.DB) http.HandlerFunc { ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 90*time.Second) defer cancel() + mode := strings.TrimSpace(r.URL.Query().Get("mode")) + groupLevels := splitCSVQuery(r.URL.Query().Get("group_levels")) + limit := intQuery(r, "limit", 50000) expanded := map[string]bool{} - for _, key := range strings.Split(r.URL.Query().Get("expanded_keys"), ",") { - key = strings.TrimSpace(key) - if key != "" { - expanded[key] = true + if r.Method == http.MethodPost { + var body struct { + Mode string `json:"mode"` + GroupLevels []string `json:"group_levels"` + ExpandedKeys []string `json:"expanded_keys"` + Limit int `json:"limit"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "gecersiz grup istegi: "+err.Error(), http.StatusBadRequest) + return + } + if strings.TrimSpace(body.Mode) != "" { + mode = strings.TrimSpace(body.Mode) + } + if len(body.GroupLevels) > 0 { + groupLevels = body.GroupLevels + } + if body.Limit > 0 { + limit = body.Limit + } + for _, key := range body.ExpandedKeys { + key = strings.TrimSpace(key) + if key != "" { + expanded[key] = true + } + } + } else { + for _, key := range strings.Split(r.URL.Query().Get("expanded_keys"), ",") { + key = strings.TrimSpace(key) + if key != "" { + expanded[key] = true + } } } rows, err := queries.ListProductPerformanceGrouped(ctx, pg, queries.ProductPerformanceGroupedRequest{ - Mode: strings.TrimSpace(r.URL.Query().Get("mode")), - GroupLevels: splitCSVQuery(r.URL.Query().Get("group_levels")), + Mode: mode, + GroupLevels: groupLevels, ExpandedKeys: expanded, - Limit: intQuery(r, "limit", 50000), + Limit: limit, }) if err != nil { http.Error(w, "urun performans grup verisi alinamadi: "+err.Error(), http.StatusInternalServerError) diff --git a/ui/src/pages/ProductPerformanceProfitability.vue b/ui/src/pages/ProductPerformanceProfitability.vue index aafe8d6..f68e0f0 100644 --- a/ui/src/pages/ProductPerformanceProfitability.vue +++ b/ui/src/pages/ProductPerformanceProfitability.vue @@ -201,7 +201,7 @@ flat bordered row-key="row_key" - class="performance-table sticky-dim-table sticky-dim-10 bg-white" + class="performance-table sticky-dim-table sticky-dim-8 bg-white" :rows="displayOrderProductCustomerTableRows" :columns="orderProductCustomerColumns" :loading="loading || backendGroupedLoading" @@ -676,7 +676,7 @@ flat bordered row-key="row_key" - class="performance-table sticky-dim-table sticky-dim-6 bg-white" + class="performance-table sticky-dim-table sticky-dim-11 bg-white" :rows="displayIdleTableRows" :columns="idleColumns" :loading="loading || backendGroupedLoading" @@ -1515,14 +1515,14 @@ const columnFilters = reactive({}) const columnFilterSearch = reactive({}) const expandedGroups = ref({}) const selectedExpandLevelKeysByTab = reactive({ - products: ['kategori', 'askili_yan', 'urun_ilk_grubu', 'urun_ana_grubu', 'urun_alt_grubu', 'market_key'], - sales_color_yaka_market_customer: ['color_code', 'yaka_kodu', 'market_key', 'customer_code'], - idle: ['kategori', 'askili_yan', 'urun_ilk_grubu', 'urun_ana_grubu', 'urun_alt_grubu', 'product_code'], - sales_product_country_segment_market_customer: ['product_code', 'country', 'customer_segment', 'market_key', 'customer_code'], - sales_market_customer_product: ['market_key', 'customer_code', 'product_code', 'color_code', 'yaka_kodu'], - sales_country_segment_market_customer_product: ['country', 'customer_segment', 'market_key', 'customer_code', 'product_code', 'color_code', 'yaka_kodu'], - order_product_customers: ['product_code', 'market_key', 'customer_code'], - order_market_details: ['market_key', 'customer_code', 'product_code', 'color_code', 'yaka_kodu'] + products: ['kategori', 'askili_yan', 'urun_ilk_grubu', 'urun_ana_grubu', 'urun_alt_grubu', 'product_code', 'item_description', 'color_code', 'yaka_kodu', 'market_key'], + sales_color_yaka_market_customer: ['color_code', 'yaka_kodu', 'askili_yan', 'urun_ilk_grubu', 'urun_ana_grubu', 'urun_alt_grubu', 'product_code', 'item_description', 'country', 'market_key', 'customer_segment', 'customer_code', 'customer_name'], + idle: ['askili_yan', 'urun_ilk_grubu', 'urun_ana_grubu', 'urun_alt_grubu', 'product_code', 'item_description', 'color_code', 'yaka_kodu'], + sales_product_country_segment_market_customer: ['market_key', 'customer_code', 'customer_name', 'kategori', 'askili_yan', 'urun_ilk_grubu', 'urun_ana_grubu', 'urun_alt_grubu', 'product_code', 'item_description', 'color_code', 'yaka_kodu'], + sales_market_customer_product: ['market_key', 'customer_code', 'customer_name', 'kategori', 'askili_yan', 'urun_ilk_grubu', 'urun_ana_grubu', 'urun_alt_grubu', 'product_code', 'item_description', 'color_code', 'yaka_kodu'], + sales_country_segment_market_customer_product: ['market_key', 'customer_code', 'customer_name', 'kategori', 'askili_yan', 'urun_ilk_grubu', 'urun_ana_grubu', 'urun_alt_grubu', 'product_code', 'item_description', 'color_code', 'yaka_kodu'], + order_product_customers: ['market_key', 'customer_code', 'customer_name', 'kategori', 'askili_yan', 'urun_ilk_grubu', 'urun_ana_grubu', 'urun_alt_grubu', 'product_code', 'item_description', 'color_code', 'yaka_kodu'], + order_market_details: ['market_key', 'customer_code', 'customer_name', 'kategori', 'askili_yan', 'urun_ilk_grubu', 'urun_ana_grubu', 'urun_alt_grubu', 'product_code', 'item_description', 'color_code', 'yaka_kodu'] }) const productKpiFetchLimit = 50000 const performanceTabs = [ @@ -1530,7 +1530,6 @@ const performanceTabs = [ { name: 'sales_color_yaka_market_customer', icon: 'palette', label: 'Renk > Yaka > Piyasa > Müşteri' }, { name: 'idle', icon: 'warning', label: 'Atıl Stok / Maliyet' }, { name: 'sales_product_country_segment_market_customer', icon: 'account_tree', label: 'Ürün > Ülke > Segment > Piyasa > Müşteri' }, - { name: 'sales_market_customer_product', icon: 'groups', label: 'Piyasa > Müşteri > Ürün Satış KPI' }, { name: 'sales_country_segment_market_customer_product', icon: 'public', label: 'Ülke > Segment > Piyasa > Müşteri > Ürün Satış KPI' }, { name: 'order_product_customers', icon: 'assignment_ind', label: 'Ürün > Piyasa > Müşteri Sipariş' }, { name: 'order_market_details', icon: 'receipt_long', label: 'Piyasa > Müşteri > Ürün Sipariş' } @@ -1571,7 +1570,6 @@ const customerBreakdownOptions = [ const salesBreakdownModes = { sales_color_yaka_market_customer: 'color_yaka_market_customer', sales_product_country_segment_market_customer: 'product_country_segment_market_customer', - sales_market_customer_product: 'market_customer_product', sales_country_segment_market_customer_product: 'country_segment_market_customer_product' } @@ -1596,15 +1594,15 @@ const detailSalesColumns = [ const columns = [ { name: 'image', label: 'Foto', field: 'image', align: 'center' }, - { name: 'product_code', label: 'Ürün', field: 'product_code', align: 'left', sortable: true }, - { name: 'color_code', label: 'Renk', field: 'color_code', align: 'left' }, - { name: 'yaka_kodu', label: 'Yaka', field: 'yaka_kodu', align: 'left' }, - { name: 'item_description', label: 'Açıklama', field: 'item_description', align: 'left' }, { name: 'kategori', label: 'Kategori', field: 'kategori', align: 'left' }, { name: 'askili_yan', label: 'Askılı/Yan', field: 'askili_yan', align: 'left' }, { name: 'urun_ilk_grubu', label: 'Ürün İlk Grubu', field: row => row.urun_ilk_grubu || row.yas_grubu || '', align: 'left' }, { name: 'urun_ana_grubu', label: 'Ürün Ana Grubu', field: row => row.urun_ana_grubu || row.seri || '', align: 'left' }, { name: 'urun_alt_grubu', label: 'Ürün Alt Grubu', field: 'urun_alt_grubu', align: 'left' }, + { name: 'product_code', label: 'Ürün', field: 'product_code', align: 'left', sortable: true }, + { name: 'item_description', label: 'Açıklama', field: 'item_description', align: 'left' }, + { name: 'color_code', label: 'Renk', field: 'color_code', align: 'left' }, + { name: 'yaka_kodu', label: 'Yaka', field: 'yaka_kodu', align: 'left' }, { name: 'market_key', label: 'Piyasa', field: row => displayMarketName(row.market_key), align: 'left', sortable: true }, { name: 'stock_qty', label: 'Stok', field: row => formatNumber(row.stock_qty, 0), align: 'right', sortable: true }, { name: 'sales_qty_90d', label: '90G Satış', field: row => formatNumber(row.sales_qty_90d, 0), align: 'right', sortable: true }, @@ -1773,14 +1771,19 @@ const orderGroupColumns = [ ] const orderProductCustomerColumns = [ - { name: 'image', label: 'Foto', field: 'image', align: 'center' }, - { name: 'product_code', label: 'Ürün', field: 'product_code', align: 'left', sortable: true }, - { name: 'color_code', label: 'Renk', field: 'color_code', align: 'left', sortable: true }, - { name: 'yaka_kodu', label: 'Yaka', field: 'yaka_kodu', align: 'left', sortable: true }, - { name: 'item_description', label: 'Açıklama', field: 'item_description', align: 'left', sortable: true }, { name: 'market_key', label: 'Piyasa', field: row => displayMarketName(row.market_key), align: 'left', sortable: true }, { name: 'customer_code', label: 'Müşteri Kodu', field: 'customer_code', align: 'left', sortable: true }, { name: 'customer_name', label: 'Müşteri', field: 'customer_name', align: 'left', sortable: true }, + { name: 'image', label: 'Foto', field: 'image', align: 'center' }, + { name: 'kategori', label: 'Kategori', field: 'kategori', align: 'left', sortable: true }, + { name: 'askili_yan', label: 'Askılı/Yan', field: 'askili_yan', align: 'left', sortable: true }, + { name: 'urun_ilk_grubu', label: 'Ürün İlk Grubu', field: row => row.urun_ilk_grubu || row.yas_grubu || '', align: 'left', sortable: true }, + { name: 'urun_ana_grubu', label: 'Ürün Ana Grubu', field: row => row.urun_ana_grubu || row.seri || '', align: 'left', sortable: true }, + { name: 'urun_alt_grubu', label: 'Ürün Alt Grubu', field: 'urun_alt_grubu', align: 'left', sortable: true }, + { name: 'product_code', label: 'Ürün', field: 'product_code', align: 'left', sortable: true }, + { name: 'item_description', label: 'Açıklama', field: 'item_description', align: 'left', sortable: true }, + { name: 'color_code', label: 'Renk', field: 'color_code', align: 'left', sortable: true }, + { name: 'yaka_kodu', label: 'Yaka', field: 'yaka_kodu', align: 'left', sortable: true }, { name: 'order_qty', label: 'Açık Sipariş', field: row => formatNumber(row.order_qty, 0), align: 'right', sortable: true }, { name: 'order_usd', label: 'Sipariş USD', field: row => formatMoney(row.order_usd, 'USD'), align: 'right', sortable: true }, { name: 'avg_order_price_usd', label: 'Ort. USD', field: row => formatMoney(row.avg_order_price_usd, 'USD'), align: 'right', sortable: true }, @@ -1803,13 +1806,18 @@ const orderMarketDetailColumns = [ { name: 'market_key', label: 'Piyasa', field: row => displayMarketName(row.market_key), align: 'left', sortable: true }, { name: 'customer_code', label: 'Müşteri Kodu', field: 'customer_code', align: 'left', sortable: true }, { name: 'customer_name', label: 'Müşteri', field: 'customer_name', align: 'left', sortable: true }, + { name: 'kategori', label: 'Kategori', field: 'kategori', align: 'left', sortable: true }, + { name: 'askili_yan', label: 'Askılı/Yan', field: 'askili_yan', align: 'left', sortable: true }, + { name: 'urun_ilk_grubu', label: 'Ürün İlk Grubu', field: row => row.urun_ilk_grubu || row.yas_grubu || '', align: 'left', sortable: true }, + { name: 'urun_ana_grubu', label: 'Ürün Ana Grubu', field: row => row.urun_ana_grubu || row.seri || '', align: 'left', sortable: true }, + { name: 'urun_alt_grubu', label: 'Ürün Alt Grubu', field: 'urun_alt_grubu', align: 'left', sortable: true }, + { name: 'product_code', label: 'Ürün', field: 'product_code', align: 'left', sortable: true }, + { name: 'item_description', label: 'Açıklama', field: 'item_description', align: 'left', sortable: true }, + { name: 'color_code', label: 'Renk', field: 'color_code', align: 'left', sortable: true }, + { name: 'yaka_kodu', label: 'Yaka', field: 'yaka_kodu', align: 'left', sortable: true }, { name: 'order_number', label: 'Sipariş No', field: 'order_number', align: 'left', sortable: true }, { name: 'order_date', label: 'Sipariş Tarihi', field: 'order_date', align: 'left', sortable: true }, { name: 'due_date', label: 'Termin', field: 'due_date', align: 'left', sortable: true }, - { name: 'product_code', label: 'Ürün', field: 'product_code', align: 'left', sortable: true }, - { name: 'color_code', label: 'Renk', field: 'color_code', align: 'left', sortable: true }, - { name: 'yaka_kodu', label: 'Yaka', field: 'yaka_kodu', align: 'left', sortable: true }, - { name: 'item_description', label: 'Açıklama', field: 'item_description', align: 'left', sortable: true }, { name: 'order_qty', label: 'Adet', field: row => formatNumber(row.order_qty, 0), align: 'right', sortable: true }, { name: 'order_usd', label: 'USD', field: row => formatMoney(row.order_usd, 'USD'), align: 'right', sortable: true }, { name: 'avg_order_price_usd', label: 'Ort. USD', field: row => formatMoney(row.avg_order_price_usd, 'USD'), align: 'right', sortable: true }, @@ -1826,15 +1834,16 @@ const orderMarketDetailColumns = [ const idleColumns = [ { name: 'image', label: 'Foto', field: 'image', align: 'center' }, - { name: 'product_code', label: 'Ürün', field: 'product_code', align: 'left', sortable: true }, - { name: 'color_code', label: 'Renk', field: 'color_code', align: 'left' }, - { name: 'yaka_kodu', label: 'Yaka', field: 'yaka_kodu', align: 'left' }, - { name: 'item_description', label: 'Açıklama', field: 'item_description', align: 'left' }, { name: 'kategori', label: 'Kategori', field: 'kategori', align: 'left' }, { name: 'askili_yan', label: 'Askılı/Yan', field: 'askili_yan', align: 'left' }, { name: 'urun_ilk_grubu', label: 'Ürün İlk Grubu', field: row => row.urun_ilk_grubu || row.yas_grubu || '', align: 'left' }, { name: 'urun_ana_grubu', label: 'Ürün Ana Grubu', field: row => row.urun_ana_grubu || row.seri || '', align: 'left' }, { name: 'urun_alt_grubu', label: 'Ürün Alt Grubu', field: 'urun_alt_grubu', align: 'left' }, + { name: 'product_code', label: 'Ürün', field: 'product_code', align: 'left', sortable: true }, + { name: 'item_description', label: 'Açıklama', field: 'item_description', align: 'left' }, + { name: 'color_code', label: 'Renk', field: 'color_code', align: 'left' }, + { name: 'yaka_kodu', label: 'Yaka', field: 'yaka_kodu', align: 'left' }, + { name: 'market_key', label: 'Piyasa', field: row => displayMarketName(row.market_key), align: 'left' }, { name: 'stock_qty', label: 'Stok', field: row => formatNumber(row.stock_qty, 0), align: 'right', sortable: true }, { name: 'cost_price_usd', label: 'Maliyet USD', field: row => formatMoney(row.cost_price_usd, 'USD'), align: 'right', sortable: true }, { name: 'idle_cost_usd', label: 'Stok Maliyeti USD', field: 'idle_cost_usd', align: 'right', sortable: true }, @@ -1888,6 +1897,7 @@ const customerColumns = [ { name: 'sales_qty_90d', label: '90G Adet', field: row => formatNumber(row.sales_qty_90d, 0), align: 'right', sortable: true }, { name: 'sales_usd_90d', label: '90G USD', field: 'sales_usd_90d', align: 'right', sortable: true }, { name: 'avg_price_usd_90d', label: 'Ort. USD', field: 'avg_price_usd_90d', align: 'right', sortable: true }, + { name: 'customer_score_90d', label: '90G Müşteri Skor', field: row => formatNumber(row.customer_score_90d, 1), align: 'right', sortable: true }, { name: 'base_price_usd_90d', label: '90G Taban', field: row => formatMoney(row.base_price_usd_90d, 'USD'), align: 'right', sortable: true }, { name: 'cost_price_usd_90d', label: '90G Çıplak', field: row => formatMoney(row.cost_price_usd_90d, 'USD'), align: 'right', sortable: true }, { name: 'gross_profit_base_usd_90d', label: '90G Taban K/Z', field: row => formatMoney(row.gross_profit_base_usd_90d, 'USD'), align: 'right', sortable: true }, @@ -1901,52 +1911,65 @@ const customerColumns = [ ] const salesBreakdownColumns = [ - { name: 'product_code', label: 'Ürün', field: 'product_code', align: 'left', sortable: true }, { name: 'color_code', label: 'Renk', field: 'color_code', align: 'left', sortable: true }, { name: 'yaka_kodu', label: 'Yaka', field: 'yaka_kodu', align: 'left', sortable: true }, + { name: 'askili_yan', label: 'Askılı/Yan', field: 'askili_yan', align: 'left', sortable: true }, + { name: 'urun_ilk_grubu', label: 'Ürün İlk Grubu', field: row => row.urun_ilk_grubu || row.yas_grubu || '', align: 'left', sortable: true }, + { name: 'urun_ana_grubu', label: 'Ürün Ana Grubu', field: row => row.urun_ana_grubu || row.seri || '', align: 'left', sortable: true }, + { name: 'urun_alt_grubu', label: 'Ürün Alt Grubu', field: 'urun_alt_grubu', align: 'left', sortable: true }, + { name: 'product_code', label: 'Ürün', field: 'product_code', align: 'left', sortable: true }, { name: 'item_description', label: 'Açıklama', field: 'item_description', align: 'left', sortable: true }, { name: 'country', label: 'Ülke', field: 'country', align: 'left', sortable: true }, - { name: 'customer_segment', label: 'Segment', field: 'customer_segment', align: 'left', sortable: true }, { name: 'market_key', label: 'Piyasa', field: row => displayMarketName(row.market_key), align: 'left', sortable: true }, + { name: 'customer_segment', label: 'Segment', field: 'customer_segment', align: 'left', sortable: true }, { name: 'customer_code', label: 'Müşteri Kodu', field: 'customer_code', align: 'left', sortable: true }, { name: 'customer_name', label: 'Müşteri', field: 'customer_name', align: 'left', sortable: true }, + { name: 'kategori', label: 'Kategori', field: 'kategori', align: 'left', sortable: true }, { name: 'product_count', label: 'Ürün Sayısı', field: row => formatNumber(row.product_count, 0), align: 'right', sortable: true }, + { name: 'stock_qty', label: 'Stok', field: row => formatNumber(row.stock_qty, 0), align: 'right', sortable: true }, { name: 'customer_count_90d', label: '90G Müşteri', field: row => formatNumber(row.customer_count_90d, 0), align: 'right', sortable: true }, { name: 'invoice_count_90d', label: '90G Fatura', field: row => formatNumber(row.invoice_count_90d, 0), align: 'right', sortable: true }, { name: 'sales_qty_90d', label: '90G Adet', field: row => formatNumber(row.sales_qty_90d, 0), align: 'right', sortable: true }, { name: 'sales_usd_90d', label: '90G USD', field: 'sales_usd_90d', align: 'right', sortable: true }, { name: 'avg_price_usd_90d', label: 'Ort. USD', field: 'avg_price_usd_90d', align: 'right', sortable: true }, + { name: 'stock_turnover_90d', label: '90G Stok Devir', field: row => formatNumber(row.stock_turnover_90d, 2), align: 'right', sortable: true }, { name: 'customer_count_180d', label: '180G Müşteri', field: row => formatNumber(row.customer_count_180d, 0), align: 'right', sortable: true }, { name: 'invoice_count_180d', label: '180G Fatura', field: row => formatNumber(row.invoice_count_180d, 0), align: 'right', sortable: true }, { name: 'sales_qty_180d', label: '180G Adet', field: row => formatNumber(row.sales_qty_180d, 0), align: 'right', sortable: true }, { name: 'sales_usd_180d', label: '180G USD', field: row => formatMoney(row.sales_usd_180d, 'USD'), align: 'right', sortable: true }, { name: 'avg_price_usd_180d', label: '180G Ort. USD', field: row => formatMoney(row.avg_price_usd_180d, 'USD'), align: 'right', sortable: true }, + { name: 'stock_turnover_180d', label: '180G Stok Devir', field: row => formatNumber(row.stock_turnover_180d, 2), align: 'right', sortable: true }, { name: 'base_price_usd_180d', label: '180G Taban', field: row => formatMoney(row.base_price_usd_180d, 'USD'), align: 'right', sortable: true }, { name: 'cost_price_usd_180d', label: '180G Çıplak', field: row => formatMoney(row.cost_price_usd_180d, 'USD'), align: 'right', sortable: true }, { name: 'gross_profit_base_usd_180d', label: '180G Taban K/Z', field: row => formatMoney(row.gross_profit_base_usd_180d, 'USD'), align: 'right', sortable: true }, { name: 'gross_profit_cost_usd_180d', label: '180G Çıplak K/Z', field: row => formatMoney(row.gross_profit_cost_usd_180d, 'USD'), align: 'right', sortable: true }, { name: 'gross_margin_base_180d', label: '180G Taban Marj', field: row => formatPercent(row.gross_margin_base_180d), align: 'right', sortable: true }, { name: 'gross_margin_cost_180d', label: '180G Çıplak Marj', field: row => formatPercent(row.gross_margin_cost_180d), align: 'right', sortable: true }, + { name: 'customer_score_180d', label: '180G Müşteri Skor', field: row => formatNumber(row.customer_score_180d, 1), align: 'right', sortable: true }, { name: 'sales_qty_365d', label: '360G Adet', field: row => formatNumber(row.sales_qty_365d, 0), align: 'right', sortable: true }, { name: 'sales_usd_365d', label: '360G USD', field: row => formatMoney(row.sales_usd_365d, 'USD'), align: 'right', sortable: true }, { name: 'avg_price_usd_365d', label: '360G Ort. USD', field: row => formatMoney(row.avg_price_usd_365d, 'USD'), align: 'right', sortable: true }, + { name: 'stock_turnover_365d', label: '360G Stok Devir', field: row => formatNumber(row.stock_turnover_365d, 2), align: 'right', sortable: true }, { name: 'base_price_usd_365d', label: '360G Taban', field: row => formatMoney(row.base_price_usd_365d, 'USD'), align: 'right', sortable: true }, { name: 'cost_price_usd_365d', label: '360G Çıplak', field: row => formatMoney(row.cost_price_usd_365d, 'USD'), align: 'right', sortable: true }, { name: 'gross_profit_base_usd_365d', label: '360G Taban K/Z', field: row => formatMoney(row.gross_profit_base_usd_365d, 'USD'), align: 'right', sortable: true }, { name: 'gross_profit_cost_usd_365d', label: '360G Çıplak K/Z', field: row => formatMoney(row.gross_profit_cost_usd_365d, 'USD'), align: 'right', sortable: true }, { name: 'gross_margin_base_365d', label: '360G Taban Marj', field: row => formatPercent(row.gross_margin_base_365d), align: 'right', sortable: true }, { name: 'gross_margin_cost_365d', label: '360G Çıplak Marj', field: row => formatPercent(row.gross_margin_cost_365d), align: 'right', sortable: true }, + { name: 'customer_score_365d', label: '360G Müşteri Skor', field: row => formatNumber(row.customer_score_365d, 1), align: 'right', sortable: true }, { name: 'customer_count_total', label: 'Genel Müşteri', field: row => formatNumber(row.customer_count_total, 0), align: 'right', sortable: true }, { name: 'invoice_count_total', label: 'Genel Fatura', field: row => formatNumber(row.invoice_count_total, 0), align: 'right', sortable: true }, { name: 'sales_qty_total', label: 'Genel Adet', field: row => formatNumber(row.sales_qty_total, 0), align: 'right', sortable: true }, { name: 'sales_usd_total', label: 'Genel USD', field: row => formatMoney(row.sales_usd_total, 'USD'), align: 'right', sortable: true }, { name: 'avg_price_usd_total', label: 'Genel Ort. USD', field: row => formatMoney(row.avg_price_usd_total, 'USD'), align: 'right', sortable: true }, + { name: 'stock_turnover_total', label: 'Genel Stok Devir', field: row => formatNumber(row.stock_turnover_total, 2), align: 'right', sortable: true }, { name: 'base_price_usd_total', label: 'Genel Taban', field: row => formatMoney(row.base_price_usd_total, 'USD'), align: 'right', sortable: true }, { name: 'cost_price_usd_total', label: 'Genel Çıplak', field: row => formatMoney(row.cost_price_usd_total, 'USD'), align: 'right', sortable: true }, { name: 'gross_profit_base_usd_total', label: 'Genel Taban K/Z', field: row => formatMoney(row.gross_profit_base_usd_total, 'USD'), align: 'right', sortable: true }, { name: 'gross_profit_cost_usd_total', label: 'Genel Çıplak K/Z', field: row => formatMoney(row.gross_profit_cost_usd_total, 'USD'), align: 'right', sortable: true }, { name: 'gross_margin_base_total', label: 'Genel Taban Marj', field: row => formatPercent(row.gross_margin_base_total), align: 'right', sortable: true }, { name: 'gross_margin_cost_total', label: 'Genel Çıplak Marj', field: row => formatPercent(row.gross_margin_cost_total), align: 'right', sortable: true }, + { name: 'customer_score_total', label: 'Genel Müşteri Skor', field: row => formatNumber(row.customer_score_total, 1), align: 'right', sortable: true }, { name: 'sales_index_90d', label: 'Endeks', field: row => formatNumber(row.sales_index_90d, 2), align: 'right', sortable: true }, { name: 'performance_score', label: 'Skor', field: row => formatNumber(row.performance_score, 1), align: 'right', sortable: true }, { name: 'performance_bucket', label: 'Durum', field: 'performance_bucket', align: 'left', sortable: true }, @@ -2076,8 +2099,11 @@ const groupLevels = [ { key: 'urun_ilk_grubu', label: 'Ürün İlk Grubu', fallback: 'yas_grubu' }, { key: 'urun_ana_grubu', label: 'Ürün Ana Grubu', fallback: 'seri' }, { key: 'urun_alt_grubu', label: 'Ürün Alt Grubu' }, - { key: 'market_key', label: 'Piyasa' }, - { key: 'product_code', label: 'Ürün Kodu' } + { key: 'product_code', label: 'Ürün' }, + { key: 'item_description', label: 'Açıklama' }, + { key: 'color_code', label: 'Renk' }, + { key: 'yaka_kodu', label: 'Yaka' }, + { key: 'market_key', label: 'Piyasa' } ] const tabGroupLevels = { @@ -2085,49 +2111,95 @@ const tabGroupLevels = { sales_color_yaka_market_customer: [ { key: 'color_code', label: 'Renk' }, { key: 'yaka_kodu', label: 'Yaka' }, + { key: 'askili_yan', label: 'Askılı/Yan' }, + { key: 'urun_ilk_grubu', label: 'Ürün İlk Grubu', fallback: 'yas_grubu' }, + { key: 'urun_ana_grubu', label: 'Ürün Ana Grubu', fallback: 'seri' }, + { key: 'urun_alt_grubu', label: 'Ürün Alt Grubu' }, + { key: 'product_code', label: 'Ürün' }, + { key: 'item_description', label: 'Açıklama' }, + { key: 'country', label: 'Ülke' }, { key: 'market_key', label: 'Piyasa' }, - { key: 'customer_code', label: 'Müşteri' } + { key: 'customer_segment', label: 'Segment' }, + { key: 'customer_code', label: 'Müşteri Kodu' }, + { key: 'customer_name', label: 'Müşteri' } ], idle: [ + { key: 'askili_yan', label: 'Askılı/Yan' }, + { key: 'urun_ilk_grubu', label: 'Ürün İlk Grubu', fallback: 'yas_grubu' }, + { key: 'urun_ana_grubu', label: 'Ürün Ana Grubu', fallback: 'seri' }, + { key: 'urun_alt_grubu', label: 'Ürün Alt Grubu' }, + { key: 'product_code', label: 'Ürün' }, + { key: 'item_description', label: 'Açıklama' }, + { key: 'color_code', label: 'Renk' }, + { key: 'yaka_kodu', label: 'Yaka' } + ], + sales_product_country_segment_market_customer: [ + { key: 'market_key', label: 'Piyasa' }, + { key: 'customer_code', label: 'Müşteri Kodu' }, + { key: 'customer_name', label: 'Müşteri' }, { key: 'kategori', label: 'Kategori' }, { key: 'askili_yan', label: 'Askılı/Yan' }, { key: 'urun_ilk_grubu', label: 'Ürün İlk Grubu', fallback: 'yas_grubu' }, { key: 'urun_ana_grubu', label: 'Ürün Ana Grubu', fallback: 'seri' }, { key: 'urun_alt_grubu', label: 'Ürün Alt Grubu' }, - { key: 'product_code', label: 'Ürün Kodu' } - ], - sales_product_country_segment_market_customer: [ - { key: 'product_code', label: 'Ürün Kodu' }, - { key: 'country', label: 'Ülke' }, - { key: 'customer_segment', label: 'Segment' }, - { key: 'market_key', label: 'Piyasa' }, - { key: 'customer_code', label: 'Müşteri' } + { key: 'product_code', label: 'Ürün' }, + { key: 'item_description', label: 'Açıklama' }, + { key: 'color_code', label: 'Renk' }, + { key: 'yaka_kodu', label: 'Yaka' } ], sales_market_customer_product: [ { key: 'market_key', label: 'Piyasa' }, - { key: 'customer_code', label: 'Müşteri' }, - { key: 'product_code', label: 'Ürün Kodu' }, + { key: 'customer_code', label: 'Müşteri Kodu' }, + { key: 'customer_name', label: 'Müşteri' }, + { key: 'kategori', label: 'Kategori' }, + { key: 'askili_yan', label: 'Askılı/Yan' }, + { key: 'urun_ilk_grubu', label: 'Ürün İlk Grubu', fallback: 'yas_grubu' }, + { key: 'urun_ana_grubu', label: 'Ürün Ana Grubu', fallback: 'seri' }, + { key: 'urun_alt_grubu', label: 'Ürün Alt Grubu' }, + { key: 'product_code', label: 'Ürün' }, + { key: 'item_description', label: 'Açıklama' }, { key: 'color_code', label: 'Renk' }, { key: 'yaka_kodu', label: 'Yaka' } ], sales_country_segment_market_customer_product: [ - { key: 'country', label: 'Ülke' }, - { key: 'customer_segment', label: 'Segment' }, { key: 'market_key', label: 'Piyasa' }, - { key: 'customer_code', label: 'Müşteri' }, - { key: 'product_code', label: 'Ürün Kodu' }, + { key: 'customer_code', label: 'Müşteri Kodu' }, + { key: 'customer_name', label: 'Müşteri' }, + { key: 'kategori', label: 'Kategori' }, + { key: 'askili_yan', label: 'Askılı/Yan' }, + { key: 'urun_ilk_grubu', label: 'Ürün İlk Grubu', fallback: 'yas_grubu' }, + { key: 'urun_ana_grubu', label: 'Ürün Ana Grubu', fallback: 'seri' }, + { key: 'urun_alt_grubu', label: 'Ürün Alt Grubu' }, + { key: 'product_code', label: 'Ürün' }, + { key: 'item_description', label: 'Açıklama' }, { key: 'color_code', label: 'Renk' }, { key: 'yaka_kodu', label: 'Yaka' } ], order_product_customers: [ - { key: 'product_code', label: 'Ürün Kodu' }, { key: 'market_key', label: 'Piyasa' }, - { key: 'customer_code', label: 'Müşteri' } + { key: 'customer_code', label: 'Müşteri Kodu' }, + { key: 'customer_name', label: 'Müşteri' }, + { key: 'kategori', label: 'Kategori' }, + { key: 'askili_yan', label: 'Askılı/Yan' }, + { key: 'urun_ilk_grubu', label: 'Ürün İlk Grubu', fallback: 'yas_grubu' }, + { key: 'urun_ana_grubu', label: 'Ürün Ana Grubu', fallback: 'seri' }, + { key: 'urun_alt_grubu', label: 'Ürün Alt Grubu' }, + { key: 'product_code', label: 'Ürün' }, + { key: 'item_description', label: 'Açıklama' }, + { key: 'color_code', label: 'Renk' }, + { key: 'yaka_kodu', label: 'Yaka' } ], order_market_details: [ { key: 'market_key', label: 'Piyasa' }, - { key: 'customer_code', label: 'Müşteri' }, - { key: 'product_code', label: 'Ürün Kodu' }, + { key: 'customer_code', label: 'Müşteri Kodu' }, + { key: 'customer_name', label: 'Müşteri' }, + { key: 'kategori', label: 'Kategori' }, + { key: 'askili_yan', label: 'Askılı/Yan' }, + { key: 'urun_ilk_grubu', label: 'Ürün İlk Grubu', fallback: 'yas_grubu' }, + { key: 'urun_ana_grubu', label: 'Ürün Ana Grubu', fallback: 'seri' }, + { key: 'urun_alt_grubu', label: 'Ürün Alt Grubu' }, + { key: 'product_code', label: 'Ürün' }, + { key: 'item_description', label: 'Açıklama' }, { key: 'color_code', label: 'Renk' }, { key: 'yaka_kodu', label: 'Yaka' } ] @@ -2321,6 +2393,7 @@ function sortProductLeafRows (sourceRows) { function makeGroupRow (key, level, groupDef, value, groupRows) { const imageSource = groupRows.find(row => row?.product_code) || {} + const aggregate = aggregateGroupFields(groupRows, groupDef.key) return { __group: true, row_key: `group|${key}`, @@ -2343,6 +2416,10 @@ function makeGroupRow (key, level, groupDef, value, groupRows) { urun_ana_grubu: groupDef.key === 'urun_ana_grubu' ? value : distinctSummary(groupRows, 'urun_ana_grubu', 'seri'), urun_alt_grubu: groupDef.key === 'urun_alt_grubu' ? value : distinctSummary(groupRows, 'urun_alt_grubu'), market_key: groupDef.key === 'market_key' ? value : distinctSummary(groupRows, 'market_key'), + country: groupDef.key === 'country' ? value : distinctSummary(groupRows, 'country'), + customer_segment: groupDef.key === 'customer_segment' ? value : distinctSummary(groupRows, 'customer_segment'), + customer_code: groupDef.key === 'customer_code' ? value : distinctSummary(groupRows, 'customer_code'), + customer_name: groupDef.key === 'customer_name' ? value : distinctSummary(groupRows, 'customer_name'), stock_qty: sumRows(groupRows, 'stock_qty'), sales_qty_90d: sumRows(groupRows, 'sales_qty_90d'), sales_qty_180d: sumRows(groupRows, 'sales_qty_180d'), @@ -2384,14 +2461,14 @@ function makeGroupRow (key, level, groupDef, value, groupRows) { stock_days_total: weightedAverageOrAverage(groupRows, 'stock_days_total', 'stock_qty'), sales_index_90d: averageRows(groupRows, 'sales_index_90d'), sales_index_total: averageRows(groupRows, 'sales_index_total'), - performance_score: weightedAverageOrAverage(groupRows, 'performance_score', 'sales_qty_90d'), performance_bucket: dominantValue(groupRows, 'performance_bucket'), - ...aggregateGroupFields(groupRows), + ...aggregate, + performance_score: groupPerformanceScore(aggregate, groupDef.key), recommendation: `${formatNumber(groupRows.length, 0)} satır` } } -function aggregateGroupFields (sourceRows) { +function aggregateGroupFields (sourceRows, groupField = '') { const out = {} const fields = new Set() for (const row of sourceRows) { @@ -2407,7 +2484,7 @@ function aggregateGroupFields (sourceRows) { } } - applyDerivedGroupMetrics(out, sourceRows) + applyDerivedGroupMetrics(out, sourceRows, groupField) return out } @@ -2429,7 +2506,7 @@ function weightFieldForMetric (field) { return 'sales_qty_90d' } -function applyDerivedGroupMetrics (out, sourceRows) { +function applyDerivedGroupMetrics (out, sourceRows, groupField = '') { for (const suffix of ['90d', '180d', '365d', 'total']) { const sales = Number(out[`sales_usd_${suffix}`] || 0) const qty = Number(out[`sales_qty_${suffix}`] || 0) @@ -2459,6 +2536,82 @@ function applyDerivedGroupMetrics (out, sourceRows) { if (sourceRows.some(row => row.performance_bucket)) { out.performance_bucket = dominantValue(sourceRows, 'performance_bucket') } + out.customer_score_90d = customerSalesPeriodScore(periodMetricSource(out, '90d')) + out.customer_score_180d = customerSalesPeriodScore(periodMetricSource(out, '180d')) + out.customer_score_365d = customerSalesPeriodScore(periodMetricSource(out, '365d')) + out.customer_score_total = customerSalesPeriodScore(periodMetricSource(out, 'total')) + out.performance_score = groupPerformanceScore(out, groupField) +} + +function groupPerformanceScore (row, groupField = '') { + if (Number(row?.order_qty || 0) > 0 || Number(row?.order_usd || 0) > 0) return orderGroupPerformanceScore(row) + if (['customer_code', 'customer_name'].includes(groupField)) return customerSalesGroupPerformanceScore(row) + return salesGroupPerformanceScore(row) +} + +function salesGroupPerformanceScore (row) { + const salesIndex = Number(row?.sales_index_90d || 0) + const margin = Number(row?.gross_margin_cost_90d ?? row?.gross_margin_90d ?? 0) + const invoices = Number(row?.invoice_count_90d || 0) + const qty = Number(row?.sales_qty_90d || 0) + let stockTurnover = Number(row?.stock_turnover_90d || 0) + const stockQty = Number(row?.stock_qty || 0) + if (!stockTurnover && stockQty > 0) stockTurnover = qty / stockQty + return Math.min(35, Math.max(0, salesIndex) * 18) + + Math.min(30, Math.max(0, margin) * 60) + + Math.min(15, invoices * 1.5) + + Math.min(10, qty / 10) + + Math.min(10, Math.max(0, stockTurnover) * 5) +} + +function customerSalesGroupPerformanceScore (row) { + const periodToScore = { + 90: Number(row?.customer_score_90d || 0), + 180: Number(row?.customer_score_180d || 0), + 360: Number(row?.customer_score_365d || 0), + total: Number(row?.customer_score_total || 0) + } + const selected = selectedPeriods.value.length ? selectedPeriods.value : ['90', '180', '360', 'total'] + const scores = selected.map(period => periodToScore[period]).filter(score => Number.isFinite(score) && score > 0) + if (!scores.length) return 0 + return scores.reduce((sum, score) => sum + score, 0) / scores.length +} + +function customerSalesPeriodScore (row) { + const salesUSD = Number(row?.sales_usd || 0) + const margin = Number(row?.gross_margin_cost ?? row?.gross_margin ?? 0) + const invoices = Number(row?.invoice_count || 0) + const qty = Number(row?.sales_qty || 0) + const productCount = Number(row?.product_count || 0) + return Math.min(35, salesUSD / 1000) + + Math.min(25, Math.max(0, margin) * 55) + + Math.min(20, invoices * 2) + + Math.min(10, qty / 10) + + Math.min(10, productCount) +} + +function periodMetricSource (row, suffix) { + return { + sales_usd: Number(row?.[`sales_usd_${suffix}`] || 0), + gross_margin_cost: Number(row?.[`gross_margin_cost_${suffix}`] ?? 0), + gross_margin: Number(row?.[`gross_margin_${suffix}`] ?? 0), + invoice_count: Number(row?.[`invoice_count_${suffix}`] || 0), + sales_qty: Number(row?.[`sales_qty_${suffix}`] || 0), + product_count: Number(row?.product_count || 0) + } +} + +function orderGroupPerformanceScore (row) { + const orderUSD = Number(row?.order_usd || 0) + const margin = Number(row?.expected_margin_cost || 0) + const orders = Number(row?.order_count || 0) + const qty = Number(row?.order_qty || 0) + const stockBonus = Number(row?.net_stock_after_order || 0) >= 0 ? 10 : 0 + return Math.min(35, orderUSD / 1000) + + Math.min(25, Math.max(0, margin) * 60) + + Math.min(15, orders * 2) + + Math.min(15, qty / 10) + + stockBonus } function sumRows (sourceRows, field) { @@ -2601,7 +2754,7 @@ function groupCellClass (colOrName) { const name = colOrName?.name || colOrName if (name === 'image') return 'product-image-cell' if (colOrName?.align === 'right') return 'text-right' - return ['stock_qty', 'sales_qty_90d', 'sales_qty_180d', 'sales_qty_365d', 'sales_qty_total', 'sales_usd_90d', 'sales_usd_180d', 'sales_usd_365d', 'sales_usd_total', 'stock_days_90d', 'stock_days_180d', 'stock_days_total', 'stock_turnover_90d', 'stock_turnover_180d', 'stock_turnover_365d', 'stock_turnover_total', 'avg_price_usd_90d', 'avg_price_usd_180d', 'avg_price_usd_365d', 'avg_price_usd_total', 'base_price_usd', 'cost_price_usd', 'unit_profit_base_90d', 'unit_profit_cost_90d', 'unit_profit_base_180d', 'unit_profit_cost_180d', 'unit_profit_base_total', 'unit_profit_cost_total', 'gross_profit_usd_90d', 'gross_profit_usd_180d', 'gross_profit_usd_total', 'gross_margin_base_90d', 'gross_margin_cost_90d', 'gross_margin_base_180d', 'gross_margin_cost_180d', 'gross_margin_base_total', 'gross_margin_cost_total', 'gross_margin_90d', 'gross_margin_180d', 'market_count_90d', 'customer_count_90d', 'market_count_total', 'customer_count_total', 'sales_index_90d', 'sales_index_total', 'performance_score'].includes(name) + return ['stock_qty', 'sales_qty_90d', 'sales_qty_180d', 'sales_qty_365d', 'sales_qty_total', 'sales_usd_90d', 'sales_usd_180d', 'sales_usd_365d', 'sales_usd_total', 'stock_days_90d', 'stock_days_180d', 'stock_days_total', 'stock_turnover_90d', 'stock_turnover_180d', 'stock_turnover_365d', 'stock_turnover_total', 'avg_price_usd_90d', 'avg_price_usd_180d', 'avg_price_usd_365d', 'avg_price_usd_total', 'base_price_usd', 'cost_price_usd', 'unit_profit_base_90d', 'unit_profit_cost_90d', 'unit_profit_base_180d', 'unit_profit_cost_180d', 'unit_profit_base_total', 'unit_profit_cost_total', 'gross_profit_usd_90d', 'gross_profit_usd_180d', 'gross_profit_usd_total', 'gross_margin_base_90d', 'gross_margin_cost_90d', 'gross_margin_base_180d', 'gross_margin_cost_180d', 'gross_margin_base_total', 'gross_margin_cost_total', 'gross_margin_90d', 'gross_margin_180d', 'market_count_90d', 'customer_count_90d', 'market_count_total', 'customer_count_total', 'sales_index_90d', 'sales_index_total', 'customer_score_90d', 'customer_score_180d', 'customer_score_365d', 'customer_score_total', 'performance_score'].includes(name) ? 'text-right' : '' } @@ -2954,8 +3107,13 @@ function normalizeOrderMarketDetailRow (row) { } function normalizeSalesBreakdownRow (row) { + const next = { ...row } + next.customer_score_90d = Number(next.customer_score_90d || customerSalesPeriodScore(periodMetricSource(next, '90d'))) + next.customer_score_180d = Number(next.customer_score_180d || customerSalesPeriodScore(periodMetricSource(next, '180d'))) + next.customer_score_365d = Number(next.customer_score_365d || customerSalesPeriodScore(periodMetricSource(next, '365d'))) + next.customer_score_total = Number(next.customer_score_total || customerSalesPeriodScore(periodMetricSource(next, 'total'))) return { - ...row, + ...next, row_key: `sales-breakdown|${row?.breakdown || ''}|${row?.product_code || ''}|${row?.color_code || ''}|${row?.yaka_kodu || ''}|${row?.country || ''}|${row?.customer_segment || ''}|${row?.market_key || ''}|${row?.customer_code || ''}` } } @@ -3165,9 +3323,13 @@ function backendGroupedSupportedTab (tabKey = activeTab.value) { } function activeExpandedGroupKeys () { + const tabPrefix = `tab:${activeTab.value}|` + const validFields = new Set(activeGroupLevels.value.map(level => level.key)) return Object.entries(expandedGroups.value) .filter(([, value]) => value === true) .map(([key]) => key) + .filter(key => key.startsWith(tabPrefix)) + .filter(key => key.split('|').slice(1).every(part => validFields.has(String(part).split(':')[0]))) } function scheduleLoadBackendGroupedRows () { @@ -3195,11 +3357,17 @@ async function loadBackendGroupedRows () { limit: tabKey === 'products' || tabKey === 'idle' ? productKpiFetchLimit : 50000 } try { + const startedAt = performance.now() const rows = await performanceStore.fetchGroupedRows(params) backendGroupedRows.value = { ...backendGroupedRows.value, [tabKey]: Array.isArray(rows) ? rows : [] } + console.info('[ProductPerformance][ui] grouped applied', { + tab: tabKey, + rows: Array.isArray(rows) ? rows.length : 0, + elapsedSec: Number(((performance.now() - startedAt) / 1000).toFixed(2)) + }) } catch (err) { console.warn('product performance grouped rows failed', err) } finally { @@ -3207,18 +3375,50 @@ async function loadBackendGroupedRows () { } } +function responseRowCount (data) { + if (Array.isArray(data)) return data.length + if (Array.isArray(data?.rows)) return data.rows.length + if (data && typeof data === 'object') return Object.keys(data).length + return 0 +} + +async function timedProductPerformanceGet (label, url, config = {}) { + const startedAt = performance.now() + console.info('[ProductPerformance][api] start', { + label, + url, + params: config?.params || {} + }) + try { + const resp = await api.get(url, config) + console.info('[ProductPerformance][api] done', { + label, + rows: responseRowCount(resp?.data), + elapsedSec: Number(((performance.now() - startedAt) / 1000).toFixed(2)) + }) + return resp + } catch (err) { + console.warn('[ProductPerformance][api] failed', { + label, + elapsedSec: Number(((performance.now() - startedAt) / 1000).toFixed(2)), + message: err?.response?.data || err?.message || err + }) + throw err + } +} + async function reload () { loading.value = true try { const salesBreakdownRequests = salesBreakdownTabKeys.map(tabKey => { - return api.get('/pricing/product-performance/sales-breakdown', { + return timedProductPerformanceGet(`sales-breakdown:${tabKey}`, '/pricing/product-performance/sales-breakdown', { params: { limit: 800, mode: salesBreakdownModes[tabKey] }, timeout: 60000 }) }) const [summaryResp, listResp, generalResp, marketResp, countryResp, customerResp, ...salesBreakdownResp] = await Promise.all([ - api.get('/pricing/product-performance/summary', { timeout: 60000 }), - api.get('/pricing/product-performance', { + timedProductPerformanceGet('summary', '/pricing/product-performance/summary', { timeout: 60000 }), + timedProductPerformanceGet('product-kpi', '/pricing/product-performance', { params: { page: 1, limit: productKpiFetchLimit, @@ -3227,10 +3427,10 @@ async function reload () { }, timeout: 60000 }), - api.get('/pricing/product-performance/general', { params: { limit: 500 }, timeout: 60000 }), - api.get('/pricing/product-performance/markets', { params: { limit: 500 }, timeout: 60000 }), - api.get('/pricing/product-performance/countries', { params: { limit: 500 }, timeout: 60000 }), - api.get('/pricing/product-performance/customers', { + timedProductPerformanceGet('general', '/pricing/product-performance/general', { params: { limit: 500 }, timeout: 60000 }), + timedProductPerformanceGet('markets', '/pricing/product-performance/markets', { params: { limit: 500 }, timeout: 60000 }), + timedProductPerformanceGet('countries', '/pricing/product-performance/countries', { params: { limit: 500 }, timeout: 60000 }), + timedProductPerformanceGet('customers', '/pricing/product-performance/customers', { params: { limit: 500, breakdown: customerBreakdown.value }, timeout: 60000 }), @@ -3275,7 +3475,7 @@ async function reload () { async function loadOrderProductCustomers (showLoading = true) { if (showLoading) loading.value = true try { - const resp = await api.get('/pricing/product-performance/orders/product-customers', { params: { limit: 500 }, timeout: 90000 }) + const resp = await timedProductPerformanceGet('orders:product-customers', '/pricing/product-performance/orders/product-customers', { params: { limit: 500 }, timeout: 90000 }) orderProductCustomerRows.value = (Array.isArray(resp?.data) ? resp.data : []).map(normalizeOrderProductCustomerRow) orderProductCustomersLoaded.value = true await primeProductImages(orderProductCustomerRows.value) @@ -3289,7 +3489,7 @@ async function loadOrderProductCustomers (showLoading = true) { async function loadOrderMarketDetails (showLoading = true) { if (showLoading) loading.value = true try { - const resp = await api.get('/pricing/product-performance/orders/market-details', { params: { limit: 800 }, timeout: 90000 }) + const resp = await timedProductPerformanceGet('orders:market-details', '/pricing/product-performance/orders/market-details', { params: { limit: 800 }, timeout: 90000 }) orderMarketDetailRows.value = (Array.isArray(resp?.data) ? resp.data : []).map(normalizeOrderMarketDetailRow) orderMarketDetailsLoaded.value = true } catch (err) { @@ -3303,9 +3503,9 @@ async function loadOrderAnalysis (showLoading = true) { if (showLoading) loading.value = true try { const [productResp, marketResp, customerResp] = await Promise.all([ - api.get('/pricing/product-performance/orders', { params: { limit: 500 }, timeout: 90000 }), - api.get('/pricing/product-performance/orders/groups', { params: { limit: 500, breakdown: 'market' }, timeout: 90000 }), - api.get('/pricing/product-performance/orders/groups', { params: { limit: 500, breakdown: 'customer' }, timeout: 90000 }) + timedProductPerformanceGet('orders:products', '/pricing/product-performance/orders', { params: { limit: 500 }, timeout: 90000 }), + timedProductPerformanceGet('orders:groups:market', '/pricing/product-performance/orders/groups', { params: { limit: 500, breakdown: 'market' }, timeout: 90000 }), + timedProductPerformanceGet('orders:groups:customer', '/pricing/product-performance/orders/groups', { params: { limit: 500, breakdown: 'customer' }, timeout: 90000 }) ]) orderAnalysisRows.value = (Array.isArray(productResp?.data) ? productResp.data : []).map(normalizeOrderAnalysisRow) orderMarketRows.value = (Array.isArray(marketResp?.data) ? marketResp.data : []).map(normalizeOrderGroupRow) @@ -3683,6 +3883,11 @@ onMounted(reload) left: 1170px; } +.sticky-dim-table :deep(.q-table th:nth-child(11)), +.sticky-dim-table :deep(.q-table td:nth-child(11)) { + left: 1300px; +} + .sticky-dim-table.sticky-dim-3 :deep(.q-table th:nth-child(-n+3)), .sticky-dim-table.sticky-dim-3 :deep(.q-table td:nth-child(-n+3)), .sticky-dim-table.sticky-dim-5 :deep(.q-table th:nth-child(-n+5)), @@ -3694,7 +3899,9 @@ onMounted(reload) .sticky-dim-table.sticky-dim-9 :deep(.q-table th:nth-child(-n+9)), .sticky-dim-table.sticky-dim-9 :deep(.q-table td:nth-child(-n+9)), .sticky-dim-table.sticky-dim-10 :deep(.q-table th:nth-child(-n+10)), -.sticky-dim-table.sticky-dim-10 :deep(.q-table td:nth-child(-n+10)) { +.sticky-dim-table.sticky-dim-10 :deep(.q-table td:nth-child(-n+10)), +.sticky-dim-table.sticky-dim-11 :deep(.q-table th:nth-child(-n+11)), +.sticky-dim-table.sticky-dim-11 :deep(.q-table td:nth-child(-n+11)) { position: sticky; z-index: 2; } @@ -3704,12 +3911,13 @@ onMounted(reload) .sticky-dim-table.sticky-dim-6 :deep(.q-table th:nth-child(-n+6)), .sticky-dim-table.sticky-dim-7 :deep(.q-table th:nth-child(-n+7)), .sticky-dim-table.sticky-dim-9 :deep(.q-table th:nth-child(-n+9)), -.sticky-dim-table.sticky-dim-10 :deep(.q-table th:nth-child(-n+10)) { +.sticky-dim-table.sticky-dim-10 :deep(.q-table th:nth-child(-n+10)), +.sticky-dim-table.sticky-dim-11 :deep(.q-table th:nth-child(-n+11)) { z-index: 4; background: #f8fbff; } -.sticky-dim-table :deep(.q-table tbody td:nth-child(-n+10)) { +.sticky-dim-table :deep(.q-table tbody td:nth-child(-n+11)) { background: #fff; } @@ -3724,7 +3932,9 @@ onMounted(reload) .sticky-dim-table.sticky-dim-9 :deep(.q-table th:nth-child(9)), .sticky-dim-table.sticky-dim-9 :deep(.q-table td:nth-child(9)), .sticky-dim-table.sticky-dim-10 :deep(.q-table th:nth-child(10)), -.sticky-dim-table.sticky-dim-10 :deep(.q-table td:nth-child(10)) { +.sticky-dim-table.sticky-dim-10 :deep(.q-table td:nth-child(10)), +.sticky-dim-table.sticky-dim-11 :deep(.q-table th:nth-child(11)), +.sticky-dim-table.sticky-dim-11 :deep(.q-table td:nth-child(11)) { box-shadow: 8px 0 10px -10px rgba(17, 24, 39, 0.45); } diff --git a/ui/src/stores/productPerformanceStore.js b/ui/src/stores/productPerformanceStore.js index 0ba4542..ec93ad7 100644 --- a/ui/src/stores/productPerformanceStore.js +++ b/ui/src/stores/productPerformanceStore.js @@ -37,9 +37,10 @@ function resolveProductImageUrl (item) { return fileName ? `/uploads/image/${fileName}` : '' } -function sortedList (value) { +function normalizedList (value, sort = false) { if (!Array.isArray(value)) return [] - return value.map(x => String(x || '').trim()).filter(Boolean).sort() + const out = value.map(x => String(x || '').trim()).filter(Boolean) + return sort ? out.sort() : out } export const useProductPerformanceStore = defineStore('product-performance-store', { @@ -64,8 +65,8 @@ export const useProductPerformanceStore = defineStore('product-performance-store groupedCacheKey (params = {}) { return JSON.stringify({ mode: String(params.mode || ''), - groupLevels: sortedList(params.groupLevels), - expandedKeys: sortedList(params.expandedKeys), + groupLevels: normalizedList(params.groupLevels), + expandedKeys: normalizedList(params.expandedKeys, true), limit: Number(params.limit || 0) }) }, @@ -75,15 +76,40 @@ export const useProductPerformanceStore = defineStore('product-performance-store const now = Date.now() const loadedAt = Number(this.groupedLoadedAtByKey[key] || 0) const cached = this.groupedRowsByKey[key] - if (!options.force && Array.isArray(cached) && now - loadedAt < GROUPED_TTL_MS) return cached - if (this.groupedInFlightByKey[key]) return this.groupedInFlightByKey[key] + const logPrefix = '[ProductPerformance][grouped]' + if (!options.force && Array.isArray(cached) && now - loadedAt < GROUPED_TTL_MS) { + console.info(`${logPrefix} cache hit`, { + mode: params.mode, + rows: cached.length, + ageMs: now - loadedAt + }) + return cached + } + if (this.groupedInFlightByKey[key]) { + console.info(`${logPrefix} in-flight reuse`, { + mode: params.mode, + groupLevels: normalizedList(params.groupLevels), + expandedKeys: normalizedList(params.expandedKeys, true).length + }) + return this.groupedInFlightByKey[key] + } this.groupedLoadingByKey = { ...this.groupedLoadingByKey, [key]: true } - const request = api.get('/pricing/product-performance/grouped', { + const startedAt = performance.now() + console.info(`${logPrefix} request start`, { + mode: params.mode, + groupLevels: normalizedList(params.groupLevels), + expandedKeys: normalizedList(params.expandedKeys, true).length, + limit: params.limit + }) + const request = api.post('/pricing/product-performance/grouped', { + mode: params.mode, + group_levels: Array.isArray(params.groupLevels) ? params.groupLevels : String(params.groupLevels || '').split(',').filter(Boolean), + expanded_keys: Array.isArray(params.expandedKeys) ? params.expandedKeys : String(params.expandedKeys || '').split(',').filter(Boolean), + limit: params.limit + }, { params: { mode: params.mode, - group_levels: Array.isArray(params.groupLevels) ? params.groupLevels.join(',') : params.groupLevels, - expanded_keys: Array.isArray(params.expandedKeys) ? params.expandedKeys.join(',') : params.expandedKeys, limit: params.limit }, timeout: 90000 @@ -91,7 +117,19 @@ export const useProductPerformanceStore = defineStore('product-performance-store const rows = Array.isArray(resp?.data) ? resp.data : [] this.groupedRowsByKey = { ...this.groupedRowsByKey, [key]: rows } this.groupedLoadedAtByKey = { ...this.groupedLoadedAtByKey, [key]: Date.now() } + console.info(`${logPrefix} request done`, { + mode: params.mode, + rows: rows.length, + elapsedSec: Number(((performance.now() - startedAt) / 1000).toFixed(2)) + }) return rows + }).catch(err => { + console.warn(`${logPrefix} request failed`, { + mode: params.mode, + elapsedSec: Number(((performance.now() - startedAt) / 1000).toFixed(2)), + message: err?.response?.data || err?.message || err + }) + throw err }).finally(() => { const nextLoading = { ...this.groupedLoadingByKey } const nextInFlight = { ...this.groupedInFlightByKey }