From d419f52e3ce2e5d63d3f971e616cae053ac7b9d2 Mon Sep 17 00:00:00 2001 From: M_Kececi Date: Thu, 2 Jul 2026 23:07:48 +0300 Subject: [PATCH] ui: update ProductPerformanceProfitability page to enhance table views and tabs management --- svc/main.go | 5 + svc/models/product_performance.go | 62 ++ svc/queries/product_performance.go | 527 ++++++++++- svc/queries/product_performance_sql.go | 47 +- svc/routes/product_performance.go | 21 + .../pages/ProductPerformanceProfitability.vue | 845 ++++++++++++++++-- 6 files changed, 1373 insertions(+), 134 deletions(-) diff --git a/svc/main.go b/svc/main.go index 3e78146..c053f56 100644 --- a/svc/main.go +++ b/svc/main.go @@ -979,6 +979,11 @@ func InitRoutes(pgDB *sql.DB, mssql *sql.DB, ml *mailer.GraphMailer) *mux.Router "pricing", "view", wrapV3(routes.GetProductPerformanceCustomersHandler(pgDB)), ) + bindV3(r, pgDB, + "/api/pricing/product-performance/sales-breakdown", "GET", + "pricing", "view", + wrapV3(routes.GetProductPerformanceSalesBreakdownHandler(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 c56289a..c14d812 100644 --- a/svc/models/product_performance.go +++ b/svc/models/product_performance.go @@ -282,6 +282,68 @@ type ProductPerformanceCustomerRow struct { LastSaleDate string `json:"last_sale_date"` } +type ProductPerformanceSalesBreakdownRow struct { + Breakdown string `json:"breakdown"` + ProductCode string `json:"product_code"` + ColorCode string `json:"color_code"` + YakaKodu string `json:"yaka_kodu"` + ItemDescription string `json:"item_description"` + 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"` + 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"` + HasCost bool `json:"has_cost"` + SalesIndex90 float64 `json:"sales_index_90d"` + PerformanceScore float64 `json:"performance_score"` + PerformanceBucket string `json:"performance_bucket"` + Recommendation string `json:"recommendation"` + LastSaleDate string `json:"last_sale_date"` +} + type ProductPerformanceSalesDetailRow struct { SalesDate string `json:"sales_date"` RefNumber string `json:"ref_number"` diff --git a/svc/queries/product_performance.go b/svc/queries/product_performance.go index f1adf41..e0ca0d7 100644 --- a/svc/queries/product_performance.go +++ b/svc/queries/product_performance.go @@ -53,11 +53,9 @@ func productPerformanceStockQuery() string { ;WITH ActiveWarehouses AS ( SELECT WarehouseCode FROM (VALUES - ('1-0-12'),('1.01.2014'),('1.02.2005'),('1.02.2004'),('1-0-43'), - ('4.02.2001'),('1-0-55'),('1.01.2003'),('1-0-21'),('1-0-2'), - ('1.01.2004'),('1-0-49'),('1-0-37'),('1-0-29'),('1-0-28'), - ('1-0-10'),('100'),('1.02.2006'),('1-0-42'),('1.01.2002'), - ('1-0-52') + ('1-0-14'),('1-0-10'),('1-0-8'),('1-2-5'),('1-2-4'),('1-0-12'),('100'),('1-0-28'), + ('1-0-24'),('1-2-6'),('1-1-14'),('1-0-2'),('1-0-52'),('1-1-2'),('1-0-21'),('1-1-3'), + ('1-0-33'),('101'),('1-014'),('1-0-49'),('1-0-36') ) W(WarehouseCode) ), Raw AS ( @@ -72,8 +70,8 @@ Raw AS ( END, InQty = SUM(S.In_Qty1), OutQty = SUM(S.Out_Qty1), - NetQty = SUM(S.InventoryQty1) - FROM StockWithCost S WITH(NOLOCK) + NetQty = SUM(S.In_Qty1 - S.Out_Qty1) + FROM trStock S WITH(NOLOCK) INNER JOIN ActiveWarehouses W ON W.WarehouseCode = LTRIM(RTRIM(S.WarehouseCode)) WHERE S.ItemTypeCode = 1 @@ -185,6 +183,103 @@ Running AS ( ConsumptionOutQty, CountDiffQty FROM Collapsed +), +CurrentStock AS ( + SELECT + ProductCode = LTRIM(RTRIM(S.ItemCode)), + ColorCode = LTRIM(RTRIM(ISNULL(S.ColorCode, ''))), + YakaKodu = LTRIM(RTRIM(ISNULL(S.ItemDim2Code, ''))), + InventoryQty1 = SUM(S.In_Qty1 - S.Out_Qty1) + FROM trStock S WITH(NOLOCK) + INNER JOIN ActiveWarehouses W + ON W.WarehouseCode = LTRIM(RTRIM(S.WarehouseCode)) + WHERE S.ItemTypeCode = 1 + AND ( + LEN(LTRIM(RTRIM(S.ItemCode))) = 13 + AND ( + S.ItemCode LIKE 'S%' + OR S.ItemCode LIKE 'O%' + OR S.ItemCode LIKE 'N%' + OR S.ItemCode LIKE 'X%' + OR S.ItemCode LIKE 'I%' + OR S.ItemCode LIKE 'A%' + ) + ) + AND (@p3 = '' OR S.ItemCode LIKE @p3 + '%') + AND S.DocumentDate < DATEADD(DAY, 1, @p2) + GROUP BY S.ItemCode, S.ColorCode, S.ItemDim2Code +), +CurrentPick AS ( + SELECT + ProductCode = LTRIM(RTRIM(P.ItemCode)), + ColorCode = LTRIM(RTRIM(ISNULL(P.ColorCode, ''))), + YakaKodu = LTRIM(RTRIM(ISNULL(P.ItemDim2Code, ''))), + PickingQty1 = SUM(P.Qty1) + FROM PickingStates P WITH(NOLOCK) + INNER JOIN ActiveWarehouses W + ON W.WarehouseCode = LTRIM(RTRIM(P.WarehouseCode)) + WHERE P.ItemTypeCode = 1 + AND LEN(LTRIM(RTRIM(P.ItemCode))) = 13 + AND (@p3 = '' OR P.ItemCode LIKE @p3 + '%') + GROUP BY P.ItemCode, P.ColorCode, P.ItemDim2Code +), +CurrentReserve AS ( + SELECT + ProductCode = LTRIM(RTRIM(R.ItemCode)), + ColorCode = LTRIM(RTRIM(ISNULL(R.ColorCode, ''))), + YakaKodu = LTRIM(RTRIM(ISNULL(R.ItemDim2Code, ''))), + ReserveQty1 = SUM(R.Qty1) + FROM ReserveStates R WITH(NOLOCK) + INNER JOIN ActiveWarehouses W + ON W.WarehouseCode = LTRIM(RTRIM(R.WarehouseCode)) + WHERE R.ItemTypeCode = 1 + AND LEN(LTRIM(RTRIM(R.ItemCode))) = 13 + AND (@p3 = '' OR R.ItemCode LIKE @p3 + '%') + GROUP BY R.ItemCode, R.ColorCode, R.ItemDim2Code +), +CurrentDisp AS ( + SELECT + ProductCode = LTRIM(RTRIM(D.ItemCode)), + ColorCode = LTRIM(RTRIM(ISNULL(D.ColorCode, ''))), + YakaKodu = LTRIM(RTRIM(ISNULL(D.ItemDim2Code, ''))), + DispOrderQty1 = SUM(D.Qty1) + FROM DispOrderStates D WITH(NOLOCK) + INNER JOIN ActiveWarehouses W + ON W.WarehouseCode = LTRIM(RTRIM(D.WarehouseCode)) + WHERE D.ItemTypeCode = 1 + AND LEN(LTRIM(RTRIM(D.ItemCode))) = 13 + AND (@p3 = '' OR D.ItemCode LIKE @p3 + '%') + GROUP BY D.ItemCode, D.ColorCode, D.ItemDim2Code +), +CurrentAvailable AS ( + SELECT + StockDate = @p2, + S.ProductCode, + S.ColorCode, + S.YakaKodu, + StockQty = CAST(ROUND( + ISNULL(S.InventoryQty1, 0) + - ISNULL(PK.PickingQty1, 0) + - ISNULL(RS.ReserveQty1, 0) + - ISNULL(DP.DispOrderQty1, 0), + 2 + ) AS decimal(18,4)), + InQty = CAST(0 AS decimal(18,4)), + OutQty = CAST(0 AS decimal(18,4)), + KpiInQty = CAST(0 AS decimal(18,4)), + KpiOutQty = CAST(0 AS decimal(18,4)), + SalesMovementQty = CAST(0 AS decimal(18,4)), + ProductionInQty = CAST(0 AS decimal(18,4)), + PurchaseInQty = CAST(0 AS decimal(18,4)), + ConsumptionOutQty = CAST(0 AS decimal(18,4)), + CountDiffQty = CAST(0 AS decimal(18,4)) + FROM CurrentStock S + LEFT JOIN CurrentPick PK + ON PK.ProductCode = S.ProductCode AND PK.ColorCode = S.ColorCode AND PK.YakaKodu = S.YakaKodu + LEFT JOIN CurrentReserve RS + ON RS.ProductCode = S.ProductCode AND RS.ColorCode = S.ColorCode AND RS.YakaKodu = S.YakaKodu + LEFT JOIN CurrentDisp DP + ON DP.ProductCode = S.ProductCode AND DP.ColorCode = S.ColorCode AND DP.YakaKodu = S.YakaKodu ) SELECT StockDate, @@ -201,8 +296,11 @@ SELECT PurchaseInQty, ConsumptionOutQty, CountDiffQty -FROM Running -WHERE StockDate BETWEEN @p1 AND @p2 +FROM ( + SELECT * FROM Running WHERE StockDate BETWEEN @p1 AND DATEADD(DAY, -1, @p2) + UNION ALL + SELECT * FROM CurrentAvailable +) X ORDER BY StockDate, ProductCode, ColorCode, YakaKodu ` } @@ -2165,6 +2263,326 @@ LIMIT $1 return out, rows.Err() } +func ListProductPerformanceSalesBreakdown(ctx context.Context, pg *sql.DB, breakdown string, limit int) ([]models.ProductPerformanceSalesBreakdownRow, error) { + if err := EnsureProductPerformanceTables(pg); err != nil { + return nil, err + } + if limit <= 0 || limit > 1000 { + limit = 500 + } + mode := strings.ToLower(strings.TrimSpace(breakdown)) + fields := map[string]string{ + "product_code": "s.product_code", + "color_code": "s.color_code", + "yaka_kodu": "s.yaka_kodu", + "item_description": "MAX(s.item_description)", + "market_key": "s.market_key", + "country": "s.customer_country", + "customer_segment": "s.customer_segment", + "customer_code": "s.customer_code", + "customer_name": "MAX(s.customer_name)", + } + selected := map[string]bool{} + groupCols := []string{} + switch mode { + case "color_yaka_market_customer": + 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["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"} + case "market_customer_product": + selected["market_key"] = true + selected["customer_code"] = true + selected["customer_name"] = true + selected["product_code"] = true + selected["color_code"] = true + selected["yaka_kodu"] = true + selected["item_description"] = 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 + selected["customer_segment"] = true + selected["market_key"] = true + selected["customer_code"] = true + selected["customer_name"] = true + selected["product_code"] = true + selected["color_code"] = true + selected["yaka_kodu"] = true + selected["item_description"] = 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["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"} + } + + selectExpr := func(name string) string { + if selected[name] { + return fields[name] + " AS " + name + } + return "''::text AS " + name + } + + query := fmt.Sprintf(` +WITH Agg AS ( + SELECT + $2::text AS breakdown, + %s, + %s, + %s, + %s, + %s, + %s, + %s, + %s, + %s, + COUNT(DISTINCT product_code) AS product_count, + 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, + COALESCE(SUM(sales_usd) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0) AS sales_usd_90d, + CASE WHEN COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0)=0 THEN 0 + ELSE COALESCE(SUM(sales_usd) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0) + / NULLIF(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0) + END AS avg_price_usd_90d, + CASE WHEN COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0)=0 THEN 0 + ELSE COALESCE(SUM(sales_qty * base_price_usd) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0) + / NULLIF(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0) + END AS base_price_usd_90d, + CASE WHEN COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0)=0 THEN 0 + ELSE COALESCE(SUM(sales_qty * cost_price_usd) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0) + / NULLIF(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0) + END AS cost_price_usd_90d, + COUNT(DISTINCT customer_code) FILTER (WHERE sales_date >= current_date - INTERVAL '179 days' AND COALESCE(customer_code,'') <> '')::integer AS customer_count_180d, + COALESCE(SUM(invoice_count) FILTER (WHERE sales_date >= current_date - INTERVAL '179 days'),0)::integer AS invoice_count_180d, + COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '179 days'),0) AS sales_qty_180d, + COALESCE(SUM(sales_usd) FILTER (WHERE sales_date >= current_date - INTERVAL '179 days'),0) AS sales_usd_180d, + CASE WHEN COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '179 days'),0)=0 THEN 0 + ELSE COALESCE(SUM(sales_usd) FILTER (WHERE sales_date >= current_date - INTERVAL '179 days'),0) + / NULLIF(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '179 days'),0) + END AS avg_price_usd_180d, + CASE WHEN COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '179 days'),0)=0 THEN 0 + ELSE COALESCE(SUM(sales_qty * base_price_usd) FILTER (WHERE sales_date >= current_date - INTERVAL '179 days'),0) + / NULLIF(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '179 days'),0) + END AS base_price_usd_180d, + CASE WHEN COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '179 days'),0)=0 THEN 0 + ELSE COALESCE(SUM(sales_qty * cost_price_usd) FILTER (WHERE sales_date >= current_date - INTERVAL '179 days'),0) + / NULLIF(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '179 days'),0) + 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, + 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) + END AS avg_price_usd_365d, + CASE WHEN COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '359 days'),0)=0 THEN 0 + ELSE COALESCE(SUM(sales_qty * base_price_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) + END AS base_price_usd_365d, + CASE WHEN COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '359 days'),0)=0 THEN 0 + ELSE COALESCE(SUM(sales_qty * cost_price_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) + END AS cost_price_usd_365d, + COUNT(DISTINCT customer_code) FILTER (WHERE sales_date >= DATE '2022-01-01' AND COALESCE(customer_code,'') <> '')::integer AS customer_count_total, + COALESCE(SUM(invoice_count) FILTER (WHERE sales_date >= DATE '2022-01-01'),0)::integer AS invoice_count_total, + COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= DATE '2022-01-01'),0) AS sales_qty_total, + COALESCE(SUM(sales_usd) FILTER (WHERE sales_date >= DATE '2022-01-01'),0) AS sales_usd_total, + CASE WHEN COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= DATE '2022-01-01'),0)=0 THEN 0 + ELSE COALESCE(SUM(sales_usd) FILTER (WHERE sales_date >= DATE '2022-01-01'),0) + / NULLIF(SUM(sales_qty) FILTER (WHERE sales_date >= DATE '2022-01-01'),0) + END AS avg_price_usd_total, + CASE WHEN COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= DATE '2022-01-01'),0)=0 THEN 0 + ELSE COALESCE(SUM(sales_qty * base_price_usd) FILTER (WHERE sales_date >= DATE '2022-01-01'),0) + / NULLIF(SUM(sales_qty) FILTER (WHERE sales_date >= DATE '2022-01-01'),0) + END AS base_price_usd_total, + CASE WHEN COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= DATE '2022-01-01'),0)=0 THEN 0 + ELSE COALESCE(SUM(sales_qty * cost_price_usd) FILTER (WHERE sales_date >= DATE '2022-01-01'),0) + / NULLIF(SUM(sales_qty) FILTER (WHERE sales_date >= DATE '2022-01-01'),0) + END AS cost_price_usd_total, + COALESCE(to_char(MAX(sales_date),'YYYY-MM-DD'),'') AS last_sale_date + FROM ( + SELECT + s.*, + COALESCE(pd.base_price_usd,0) AS base_price_usd, + COALESCE(pd.cost_price_usd,0) AS cost_price_usd + FROM mk_product_performance_sales_daily s + LEFT JOIN mk_product_performance_price_dim pd ON pd.product_code = s.product_code + ) s + WHERE sales_date >= DATE '2022-01-01' + GROUP BY %s +), +Enriched AS ( + SELECT + Agg.*, + sales_usd_90d - (sales_qty_90d * base_price_usd_90d) AS gross_profit_base_usd_90d, + sales_usd_90d - (sales_qty_90d * cost_price_usd_90d) AS gross_profit_cost_usd_90d, + CASE WHEN sales_usd_90d <= 0 THEN 0 ELSE (sales_usd_90d - (sales_qty_90d * base_price_usd_90d)) / NULLIF(sales_usd_90d,0) END AS gross_margin_base_90d, + CASE WHEN sales_usd_90d <= 0 THEN 0 ELSE (sales_usd_90d - (sales_qty_90d * cost_price_usd_90d)) / NULLIF(sales_usd_90d,0) END AS gross_margin_cost_90d, + sales_usd_180d - (sales_qty_180d * base_price_usd_180d) AS gross_profit_base_usd_180d, + sales_usd_180d - (sales_qty_180d * cost_price_usd_180d) AS gross_profit_cost_usd_180d, + CASE WHEN sales_usd_180d <= 0 THEN 0 ELSE (sales_usd_180d - (sales_qty_180d * base_price_usd_180d)) / NULLIF(sales_usd_180d,0) END AS gross_margin_base_180d, + CASE WHEN sales_usd_180d <= 0 THEN 0 ELSE (sales_usd_180d - (sales_qty_180d * cost_price_usd_180d)) / NULLIF(sales_usd_180d,0) END AS gross_margin_cost_180d, + sales_usd_365d - (sales_qty_365d * base_price_usd_365d) AS gross_profit_base_usd_365d, + sales_usd_365d - (sales_qty_365d * cost_price_usd_365d) AS gross_profit_cost_usd_365d, + CASE WHEN sales_usd_365d <= 0 THEN 0 ELSE (sales_usd_365d - (sales_qty_365d * base_price_usd_365d)) / NULLIF(sales_usd_365d,0) END AS gross_margin_base_365d, + CASE WHEN sales_usd_365d <= 0 THEN 0 ELSE (sales_usd_365d - (sales_qty_365d * cost_price_usd_365d)) / NULLIF(sales_usd_365d,0) END AS gross_margin_cost_365d, + sales_usd_total - (sales_qty_total * base_price_usd_total) AS gross_profit_base_usd_total, + sales_usd_total - (sales_qty_total * cost_price_usd_total) AS gross_profit_cost_usd_total, + CASE WHEN sales_usd_total <= 0 THEN 0 ELSE (sales_usd_total - (sales_qty_total * base_price_usd_total)) / NULLIF(sales_usd_total,0) END AS gross_margin_base_total, + CASE WHEN sales_usd_total <= 0 THEN 0 ELSE (sales_usd_total - (sales_qty_total * cost_price_usd_total)) / NULLIF(sales_usd_total,0) END AS gross_margin_cost_total, + (base_price_usd_total > 0 AND cost_price_usd_total > 0) AS has_cost + FROM Agg +), +Scored AS ( + SELECT + Enriched.*, + CASE WHEN AVG(NULLIF(sales_usd_90d,0)) OVER () IS NULL THEN 0 + ELSE sales_usd_90d / NULLIF(AVG(NULLIF(sales_usd_90d,0)) OVER (),0) + END AS sales_index_90d + FROM Enriched +) +SELECT + breakdown, + product_code, + color_code, + yaka_kodu, + item_description, + market_key, + country, + customer_segment, + customer_code, + customer_name, + product_count, + customer_count_90d, + invoice_count_90d, + sales_qty_90d, + sales_usd_90d, + avg_price_usd_90d, + base_price_usd_90d, + cost_price_usd_90d, + gross_profit_base_usd_90d, + gross_profit_cost_usd_90d, + gross_margin_base_90d, + gross_margin_cost_90d, + customer_count_180d, + invoice_count_180d, + sales_qty_180d, + sales_usd_180d, + avg_price_usd_180d, + base_price_usd_180d, + cost_price_usd_180d, + gross_profit_base_usd_180d, + gross_profit_cost_usd_180d, + gross_margin_base_180d, + gross_margin_cost_180d, + sales_qty_365d, + sales_usd_365d, + avg_price_usd_365d, + base_price_usd_365d, + cost_price_usd_365d, + gross_profit_base_usd_365d, + gross_profit_cost_usd_365d, + gross_margin_base_365d, + gross_margin_cost_365d, + customer_count_total, + invoice_count_total, + sales_qty_total, + sales_usd_total, + avg_price_usd_total, + base_price_usd_total, + cost_price_usd_total, + gross_profit_base_usd_total, + gross_profit_cost_usd_total, + gross_margin_base_total, + gross_margin_cost_total, + has_cost, + sales_index_90d, + 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, + 4 + ) + END AS performance_score, + CASE + WHEN NOT has_cost THEN 'MALIYET_YOK' + WHEN sales_index_90d >= 1.25 AND customer_count_90d >= 2 THEN 'YILDIZ_URUN' + WHEN sales_qty_90d = 0 AND sales_qty_365d > 0 THEN 'STOK_RISKI' + WHEN gross_margin_cost_90d < 0 THEN 'FIYAT_BASKISI' + WHEN sales_index_90d < 0.75 THEN 'TAKIP' + 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' + ELSE 'Takip' + END AS recommendation, + last_sale_date +FROM Scored +ORDER BY sales_usd_90d DESC, sales_qty_90d DESC +LIMIT $1 +`, + selectExpr("product_code"), + selectExpr("color_code"), + selectExpr("yaka_kodu"), + selectExpr("item_description"), + selectExpr("market_key"), + selectExpr("country"), + selectExpr("customer_segment"), + selectExpr("customer_code"), + selectExpr("customer_name"), + strings.Join(groupCols, ", "), + ) + + rows, err := pg.QueryContext(ctx, query, limit, mode) + if err != nil { + return nil, err + } + defer rows.Close() + out := make([]models.ProductPerformanceSalesBreakdownRow, 0, limit) + for rows.Next() { + var r models.ProductPerformanceSalesBreakdownRow + if err := rows.Scan( + &r.Breakdown, &r.ProductCode, &r.ColorCode, &r.YakaKodu, &r.ItemDescription, + &r.MarketKey, &r.Country, &r.CustomerSegment, &r.CustomerCode, &r.CustomerName, + &r.ProductCount, &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.AvgPriceUSDTotal, &r.BasePriceUSDTotal, &r.CostPriceUSDTotal, &r.GrossProfitBaseTotal, + &r.GrossProfitCostTotal, &r.GrossMarginBaseTotal, &r.GrossMarginCostTotal, &r.HasCost, + &r.SalesIndex90, &r.PerformanceScore, + &r.PerformanceBucket, &r.Recommendation, &r.LastSaleDate, + ); err != nil { + return nil, err + } + out = append(out, r) + } + return out, rows.Err() +} + func ListProductPerformanceSalesDetails(ctx context.Context, pg *sql.DB, productCode, colorCode, yakaKodu string, limit int) ([]models.ProductPerformanceSalesDetailRow, error) { if limit <= 0 || limit > 500 { limit = 100 @@ -2211,26 +2629,83 @@ func ListProductPerformanceStockSizes(ctx context.Context, productCode, colorCod ;WITH ActiveWarehouses AS ( SELECT WarehouseCode FROM (VALUES - ('1-0-12'),('1.01.2014'),('1.02.2005'),('1.02.2004'),('1-0-43'), - ('4.02.2001'),('1-0-55'),('1.01.2003'),('1-0-21'),('1-0-2'), - ('1.01.2004'),('1-0-49'),('1-0-37'),('1-0-29'),('1-0-28'), - ('1-0-10'),('100'),('1.02.2006'),('1-0-42'),('1.01.2002'), - ('1-0-52') + ('1-0-14'),('1-0-10'),('1-0-8'),('1-2-5'),('1-2-4'),('1-0-12'),('100'),('1-0-28'), + ('1-0-24'),('1-2-6'),('1-1-14'),('1-0-2'),('1-0-52'),('1-1-2'),('1-0-21'),('1-1-3'), + ('1-0-33'),('101'),('1-014'),('1-0-49'),('1-0-36') ) W(WarehouseCode) +), +Stock AS ( + SELECT + SizeCode = LTRIM(RTRIM(ISNULL(S.ItemDim1Code, ''))), + InventoryQty1 = SUM(S.In_Qty1 - S.Out_Qty1) + FROM trStock S WITH(NOLOCK) + INNER JOIN ActiveWarehouses W + ON W.WarehouseCode = LTRIM(RTRIM(S.WarehouseCode)) + WHERE S.ItemTypeCode = 1 + AND LTRIM(RTRIM(S.ItemCode)) = @p1 + AND LTRIM(RTRIM(ISNULL(S.ColorCode, ''))) = @p2 + AND LTRIM(RTRIM(ISNULL(S.ItemDim2Code, ''))) = @p3 + GROUP BY LTRIM(RTRIM(ISNULL(S.ItemDim1Code, ''))) +), +Pick AS ( + SELECT + SizeCode = LTRIM(RTRIM(ISNULL(P.ItemDim1Code, ''))), + PickingQty1 = SUM(P.Qty1) + FROM PickingStates P WITH(NOLOCK) + INNER JOIN ActiveWarehouses W + ON W.WarehouseCode = LTRIM(RTRIM(P.WarehouseCode)) + WHERE P.ItemTypeCode = 1 + AND LTRIM(RTRIM(P.ItemCode)) = @p1 + AND LTRIM(RTRIM(ISNULL(P.ColorCode, ''))) = @p2 + AND LTRIM(RTRIM(ISNULL(P.ItemDim2Code, ''))) = @p3 + GROUP BY LTRIM(RTRIM(ISNULL(P.ItemDim1Code, ''))) +), +Reserve AS ( + SELECT + SizeCode = LTRIM(RTRIM(ISNULL(R.ItemDim1Code, ''))), + ReserveQty1 = SUM(R.Qty1) + FROM ReserveStates R WITH(NOLOCK) + INNER JOIN ActiveWarehouses W + ON W.WarehouseCode = LTRIM(RTRIM(R.WarehouseCode)) + WHERE R.ItemTypeCode = 1 + AND LTRIM(RTRIM(R.ItemCode)) = @p1 + AND LTRIM(RTRIM(ISNULL(R.ColorCode, ''))) = @p2 + AND LTRIM(RTRIM(ISNULL(R.ItemDim2Code, ''))) = @p3 + GROUP BY LTRIM(RTRIM(ISNULL(R.ItemDim1Code, ''))) +), +Disp AS ( + SELECT + SizeCode = LTRIM(RTRIM(ISNULL(D.ItemDim1Code, ''))), + DispOrderQty1 = SUM(D.Qty1) + FROM DispOrderStates D WITH(NOLOCK) + INNER JOIN ActiveWarehouses W + ON W.WarehouseCode = LTRIM(RTRIM(D.WarehouseCode)) + WHERE D.ItemTypeCode = 1 + AND LTRIM(RTRIM(D.ItemCode)) = @p1 + AND LTRIM(RTRIM(ISNULL(D.ColorCode, ''))) = @p2 + AND LTRIM(RTRIM(ISNULL(D.ItemDim2Code, ''))) = @p3 + GROUP BY LTRIM(RTRIM(ISNULL(D.ItemDim1Code, ''))) ) SELECT - SizeCode = LTRIM(RTRIM(ISNULL(S.ItemDim1Code, ''))), - StockQty = SUM(S.InventoryQty1) -FROM StockWithCost S WITH(NOLOCK) -INNER JOIN ActiveWarehouses W - ON W.WarehouseCode = LTRIM(RTRIM(S.WarehouseCode)) -WHERE S.ItemTypeCode = 1 - AND LTRIM(RTRIM(S.ItemCode)) = @p1 - AND LTRIM(RTRIM(ISNULL(S.ColorCode, ''))) = @p2 - AND LTRIM(RTRIM(ISNULL(S.ItemDim2Code, ''))) = @p3 -GROUP BY LTRIM(RTRIM(ISNULL(S.ItemDim1Code, ''))) -HAVING SUM(S.InventoryQty1) <> 0 -ORDER BY LTRIM(RTRIM(ISNULL(S.ItemDim1Code, ''))) + S.SizeCode, + StockQty = CAST(ROUND( + ISNULL(S.InventoryQty1, 0) + - ISNULL(P.PickingQty1, 0) + - ISNULL(R.ReserveQty1, 0) + - ISNULL(D.DispOrderQty1, 0), + 2 + ) AS FLOAT) +FROM Stock S +LEFT JOIN Pick P ON P.SizeCode = S.SizeCode +LEFT JOIN Reserve R ON R.SizeCode = S.SizeCode +LEFT JOIN Disp D ON D.SizeCode = S.SizeCode +WHERE ( + ISNULL(S.InventoryQty1, 0) + - ISNULL(P.PickingQty1, 0) + - ISNULL(R.ReserveQty1, 0) + - ISNULL(D.DispOrderQty1, 0) +) <> 0 +ORDER BY S.SizeCode `, strings.TrimSpace(productCode), strings.TrimSpace(colorCode), strings.TrimSpace(yakaKodu)) if err != nil { return nil, err diff --git a/svc/queries/product_performance_sql.go b/svc/queries/product_performance_sql.go index f576f1e..11b2576 100644 --- a/svc/queries/product_performance_sql.go +++ b/svc/queries/product_performance_sql.go @@ -47,20 +47,6 @@ func productPerformanceSalesSQL() string { AND cdItemAttributeDesc.LangCode = 'TR' ), SPACE(0))), Kategori = dbo.HG_Temizlik(ISNULL(( - SELECT AttributeDescription - FROM cdItemAttributeDesc WITH(NOLOCK) - WHERE cdItemAttributeDesc.ItemTypeCode = I.ItemTypeCode - AND cdItemAttributeDesc.AttributeTypeCode = 42 - AND cdItemAttributeDesc.AttributeCode = ( - SELECT TOP 1 AttributeCode - FROM prItemAttribute WITH(NOLOCK) - WHERE AttributeTypeCode = 42 - AND prItemAttribute.ItemTypeCode = I.ItemTypeCode - AND prItemAttribute.ItemCode = I.ItemCode - ) - AND cdItemAttributeDesc.LangCode = 'TR' - ), SPACE(0))), - UrunIlkGrubu = dbo.HG_Temizlik(ISNULL(( SELECT AttributeDescription FROM cdItemAttributeDesc WITH(NOLOCK) WHERE cdItemAttributeDesc.ItemTypeCode = I.ItemTypeCode @@ -74,10 +60,33 @@ func productPerformanceSalesSQL() string { ) AND cdItemAttributeDesc.LangCode = 'TR' ), SPACE(0))), + UrunIlkGrubu = dbo.HG_Temizlik(ISNULL(( + SELECT AttributeDescription + FROM cdItemAttributeDesc WITH(NOLOCK) + WHERE cdItemAttributeDesc.ItemTypeCode = I.ItemTypeCode + AND cdItemAttributeDesc.AttributeTypeCode = 42 + AND cdItemAttributeDesc.AttributeCode = ( + SELECT TOP 1 AttributeCode + FROM prItemAttribute WITH(NOLOCK) + WHERE AttributeTypeCode = 42 + AND prItemAttribute.ItemTypeCode = I.ItemTypeCode + AND prItemAttribute.ItemCode = I.ItemCode + ) + AND cdItemAttributeDesc.LangCode = 'TR' + ), SPACE(0))), AskiliYan = dbo.HG_Temizlik(ISNULL(( - SELECT TOP 1 ProductAtt45 - FROM ProductAttributesFilter WITH(NOLOCK) - WHERE ProductAttributesFilter.ItemCode = I.ItemCode + SELECT AttributeDescription + FROM cdItemAttributeDesc WITH(NOLOCK) + WHERE cdItemAttributeDesc.ItemTypeCode = I.ItemTypeCode + AND cdItemAttributeDesc.AttributeTypeCode = 45 + AND cdItemAttributeDesc.AttributeCode = ( + SELECT TOP 1 AttributeCode + FROM prItemAttribute WITH(NOLOCK) + WHERE AttributeTypeCode = 45 + AND prItemAttribute.ItemTypeCode = I.ItemTypeCode + AND prItemAttribute.ItemCode = I.ItemCode + ) + AND cdItemAttributeDesc.LangCode = 'TR' ), SPACE(0))), ChannelCode = CASE WHEN I.ProcessCode IN ('R') THEN dbo.HG_Temizlik(ISNULL(( @@ -487,12 +496,12 @@ SalesAgg AS ( SUM(sales_qty) FILTER (WHERE sales_date >= $1::date - INTERVAL '29 days') AS sales_qty_30d, SUM(sales_qty) FILTER (WHERE sales_date >= $1::date - INTERVAL '89 days') AS sales_qty_90d, SUM(sales_qty) FILTER (WHERE sales_date >= $1::date - INTERVAL '179 days') AS sales_qty_180d, - SUM(sales_qty) FILTER (WHERE sales_date >= $1::date - INTERVAL '364 days') AS sales_qty_365d, + SUM(sales_qty) FILTER (WHERE sales_date >= $1::date - INTERVAL '359 days') AS sales_qty_365d, SUM(sales_qty) FILTER (WHERE sales_date >= $1::date - INTERVAL '729 days') AS sales_qty_730d, SUM(sales_usd) FILTER (WHERE sales_date >= $1::date - INTERVAL '29 days') AS sales_usd_30d, SUM(sales_usd) FILTER (WHERE sales_date >= $1::date - INTERVAL '89 days') AS sales_usd_90d, SUM(sales_usd) FILTER (WHERE sales_date >= $1::date - INTERVAL '179 days') AS sales_usd_180d, - SUM(sales_usd) FILTER (WHERE sales_date >= $1::date - INTERVAL '364 days') AS sales_usd_365d, + SUM(sales_usd) FILTER (WHERE sales_date >= $1::date - INTERVAL '359 days') AS sales_usd_365d, COUNT(DISTINCT NULLIF(customer_code, '-')) FILTER (WHERE sales_date >= $1::date - INTERVAL '89 days') AS customer_count_90d, MAX(sales_date) AS last_sale_date, MAX(last_ref_number) AS last_ref_number diff --git a/svc/routes/product_performance.go b/svc/routes/product_performance.go index 55bc6de..3d32cb0 100644 --- a/svc/routes/product_performance.go +++ b/svc/routes/product_performance.go @@ -240,6 +240,27 @@ func GetProductPerformanceCustomersHandler(pg *sql.DB) http.HandlerFunc { } } +func GetProductPerformanceSalesBreakdownHandler(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), 45*time.Second) + defer cancel() + + rows, err := queries.ListProductPerformanceSalesBreakdown( + ctx, + pg, + strings.TrimSpace(r.URL.Query().Get("mode")), + intQuery(r, "limit", 500), + ) + if err != nil { + http.Error(w, "satis kpi kirilimi alinamadi: "+err.Error(), http.StatusInternalServerError) + return + } + _ = json.NewEncoder(w).Encode(rows) + } +} + func intQuery(r *http.Request, key string, fallback int) int { raw := strings.TrimSpace(r.URL.Query().Get(key)) if raw == "" { diff --git a/ui/src/pages/ProductPerformanceProfitability.vue b/ui/src/pages/ProductPerformanceProfitability.vue index 2b08a72..731d93c 100644 --- a/ui/src/pages/ProductPerformanceProfitability.vue +++ b/ui/src/pages/ProductPerformanceProfitability.vue @@ -8,6 +8,36 @@
+ + +
+ + +
+ + +
+
+
@@ -32,13 +62,13 @@ align="left" > - - - + - - - + + + + + +
@@ -710,6 +755,111 @@ + + + + + + + +
@@ -1034,9 +1184,16 @@ const orderAnalysisMode = ref('product') const marketRows = ref([]) const countryRows = ref([]) const customerRows = ref([]) +const salesBreakdownRows = reactive({ + sales_color_yaka_market_customer: [], + sales_product_country_segment_market_customer: [], + sales_market_customer_product: [], + sales_country_segment_market_customer_product: [] +}) const summary = ref({}) const activeTab = ref('products') const customerBreakdown = ref('market_customer') +const selectedPeriods = ref(['90', '180', '360', 'total']) const imageUrlByKey = ref({}) const imageListByKey = ref({}) const imageDialog = ref(false) @@ -1055,6 +1212,16 @@ const detailStockSizes = ref([]) 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'] +}) const productKpiFetchLimit = 50000 const pagination = ref({ @@ -1071,15 +1238,32 @@ const bucketOptions = [ { label: 'Stok Riski', value: 'STOK_RISKI' }, { label: 'Fiyat Baskısı', value: 'FIYAT_BASKISI' }, { label: 'Fiyat Fırsatı', value: 'FIYAT_FIRSATI' }, + { label: 'Maliyet Yok', value: 'MALIYET_YOK' }, { label: 'Takip', value: 'TAKIP' } ] +const periodOptions = [ + { label: '90G', value: '90' }, + { label: '180G', value: '180' }, + { label: '360G', value: '360' }, + { label: 'Genel', value: 'total' } +] + const customerBreakdownOptions = [ { label: 'Piyasa > Müşteri', value: 'market_customer' }, { label: 'Ülke > Müşteri', value: 'country_customer' }, { label: 'Piyasa > Ülke > Müşteri', value: 'market_country_customer' } ] +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' +} + +const salesBreakdownTabKeys = Object.keys(salesBreakdownModes) + const detailStockColumns = [ { name: 'size_code', label: 'Beden', field: 'size_code', align: 'left', sortable: true }, { name: 'stock_qty', label: 'Stok', field: row => formatNumber(row.stock_qty, 0), align: 'right', sortable: true } @@ -1088,7 +1272,7 @@ const detailStockColumns = [ const detailSalesColumns = [ { name: 'sales_date', label: 'Tarih', field: 'sales_date', align: 'left', sortable: true }, { name: 'ref_number', label: 'Ref', field: 'ref_number', align: 'left', sortable: true }, - { name: 'market_key', label: 'Piyasa', field: 'market_key', align: 'left', sortable: true }, + { name: 'market_key', label: 'Piyasa', field: row => displayMarketName(row.market_key), align: 'left', sortable: true }, { name: 'country', label: 'Ülke', field: 'country', 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 }, @@ -1104,40 +1288,75 @@ const columns = [ { 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: 'urun_ilk_grubu', label: 'Ürün İlk Grubu', field: row => row.urun_ilk_grubu || row.yas_grubu || '', 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: 'market_key', label: 'Piyasa', field: 'market_key', align: 'left', sortable: true }, + { 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 }, { name: 'sales_qty_180d', label: '180G Satış', field: row => formatNumber(row.sales_qty_180d, 0), align: 'right', sortable: true }, + { name: 'sales_qty_365d', label: '360G Satış', field: row => formatNumber(row.sales_qty_365d, 0), align: 'right', sortable: true }, + { name: 'sales_qty_total', label: 'Genel Satış', field: row => formatNumber(row.sales_qty_total, 0), align: 'right', sortable: true }, { name: 'sales_usd_90d', label: '90G USD', field: 'sales_usd_90d', align: 'right', sortable: true }, { name: 'sales_usd_180d', label: '180G USD', field: 'sales_usd_180d', align: 'right', sortable: true }, + { name: 'sales_usd_365d', label: '360G USD', field: 'sales_usd_365d', align: 'right', sortable: true }, + { name: 'sales_usd_total', label: 'Genel USD', field: 'sales_usd_total', align: 'right', sortable: true }, { name: 'stock_days_90d', label: 'Stok Gün', field: row => formatNumber(row.stock_days_90d, 1), align: 'right', sortable: true }, { name: 'stock_days_180d', label: '180G Stok Gün', field: row => formatNumber(row.stock_days_180d, 1), align: 'right', sortable: true }, + { name: 'stock_days_total', label: 'Genel Stok Gün', field: row => formatNumber(row.stock_days_total, 1), align: 'right', sortable: true }, { name: 'avg_price_usd_90d', label: 'Ort. USD', field: row => formatMoney(row.avg_price_usd_90d, 'USD'), align: 'right' }, { name: 'avg_price_usd_180d', label: '180G Ort. USD', field: row => formatMoney(row.avg_price_usd_180d, 'USD'), align: 'right' }, + { name: 'avg_price_usd_365d', label: '360G Ort. USD', field: row => formatMoney(row.avg_price_usd_365d, 'USD'), align: 'right' }, + { name: 'avg_price_usd_total', label: 'Genel Ort. USD', field: row => formatMoney(row.avg_price_usd_total, 'USD'), align: 'right' }, { name: 'base_price_usd', label: 'Ort. USD Taban Maliyeti', field: row => formatMoney(row.base_price_usd, 'USD'), align: 'right', sortable: true }, { name: 'cost_price_usd', label: 'Ort. USD Çıplak Maliyeti', field: row => formatMoney(row.cost_price_usd, 'USD'), align: 'right', sortable: true }, { name: 'unit_profit_base_90d', label: '90G P.Başı Taban K/Z', field: row => formatMoney(row.unit_profit_base_90d, 'USD'), align: 'right', sortable: true }, { name: 'unit_profit_cost_90d', label: '90G P.Başı Çıplak K/Z', field: row => formatMoney(row.unit_profit_cost_90d, 'USD'), align: 'right', sortable: true }, { name: 'unit_profit_base_180d', label: '180G P.Başı Taban K/Z', field: row => formatMoney(row.unit_profit_base_180d, 'USD'), align: 'right', sortable: true }, { name: 'unit_profit_cost_180d', label: '180G P.Başı Çıplak K/Z', field: row => formatMoney(row.unit_profit_cost_180d, 'USD'), align: 'right', sortable: true }, + { name: 'unit_profit_base_total', label: 'Genel P.Başı Taban K/Z', field: row => formatMoney(row.unit_profit_base_total, 'USD'), align: 'right', sortable: true }, + { name: 'unit_profit_cost_total', label: 'Genel P.Başı Çıplak K/Z', field: row => formatMoney(row.unit_profit_cost_total, 'USD'), align: 'right', sortable: true }, { name: 'gross_profit_usd_90d', label: '90G Toplam K/Z', field: row => formatMoney(row.gross_profit_usd_90d, 'USD'), align: 'right', sortable: true }, { name: 'gross_profit_usd_180d', label: '180G Toplam K/Z', field: row => formatMoney(row.gross_profit_usd_180d, 'USD'), align: 'right', sortable: true }, + { name: 'gross_profit_usd_total', label: 'Genel Toplam K/Z', field: row => formatMoney(row.gross_profit_usd_total, 'USD'), align: 'right', sortable: true }, { name: 'gross_margin_base_90d', label: '90G Taban Marj', field: row => formatPercent(row.gross_margin_base_90d), align: 'right', sortable: true }, { name: 'gross_margin_cost_90d', label: '90G Çıplak Marj', field: row => formatPercent(row.gross_margin_cost_90d), 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: '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: 'market_count_90d', label: '90G Piyasa', field: row => formatNumber(row.market_count_90d, 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: 'market_count_total', label: 'Genel Piyasa', field: row => formatNumber(row.market_count_total, 0), 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: 'sales_index_90d', label: 'Piyasa End.', field: row => formatNumber(row.sales_index_90d, 2), align: 'right', sortable: true }, + { name: 'sales_index_total', label: 'Genel Endeks', field: row => formatNumber(row.sales_index_total, 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' }, { name: 'recommendation', label: 'Öneri', field: 'recommendation', align: 'left' } ] +const periodColumnRules = [ + { period: '90', match: name => name.includes('_90d') }, + { period: '180', match: name => name.includes('_180d') }, + { period: '360', match: name => name.includes('_365d') }, + { period: 'total', match: name => name.includes('_total') } +] + +const selectedPeriodSet = computed(() => new Set(selectedPeriods.value)) + +function columnPeriod (name) { + return periodColumnRules.find(rule => rule.match(name))?.period || '' +} + +function visiblePeriodColumn (col) { + const period = columnPeriod(col.name) + return !period || selectedPeriodSet.value.has(period) +} + +const productColumns = computed(() => columns.filter(visiblePeriodColumn)) + const generalColumns = [ { name: 'image', label: 'Foto', field: 'image', align: 'center' }, { name: 'product_code', label: 'Ürün', field: 'product_code', align: 'left', sortable: true }, @@ -1145,11 +1364,11 @@ const generalColumns = [ { 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: 'kategori', label: 'Kategori', field: 'kategori', 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: '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: 'market_key', label: 'Piyasa', field: 'market_key', align: 'left', sortable: true }, + { name: 'market_key', label: 'Piyasa', field: row => displayMarketName(row.market_key), align: 'left', sortable: true }, { name: 'period_start', label: 'Başlangıç', field: 'period_start', align: 'left', sortable: true }, { name: 'period_end', label: 'Bitiş', field: 'period_end', align: 'left', sortable: true }, { name: 'stock_qty', label: 'Stok', field: row => formatNumber(row.stock_qty, 0), align: 'right', sortable: true }, @@ -1183,11 +1402,11 @@ const orderAnalysisColumns = [ { 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: 'kategori', label: 'Kategori', field: 'kategori', 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: '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: 'market_key', label: 'Piyasa', field: 'market_key', align: 'left', sortable: true }, + { name: 'market_key', label: 'Piyasa', field: row => displayMarketName(row.market_key), 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. Sipariş USD', field: row => formatMoney(row.avg_order_price_usd, 'USD'), align: 'right', sortable: true }, @@ -1212,7 +1431,7 @@ const orderAnalysisColumns = [ const orderGroupColumns = [ { name: 'group_key', label: 'Kırılım', field: row => orderGroupLabel(row), align: 'left', sortable: true }, - { name: 'market_key', label: 'Piyasa', field: 'market_key', 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: 'product_count', label: 'Ürün', field: row => formatNumber(row.product_count, 0), align: 'right', sortable: true }, @@ -1242,7 +1461,7 @@ const orderProductCustomerColumns = [ { 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: 'market_key', 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: 'order_qty', label: 'Açık Sipariş', field: row => formatNumber(row.order_qty, 0), align: 'right', sortable: true }, @@ -1264,7 +1483,7 @@ const orderProductCustomerColumns = [ ] const orderMarketDetailColumns = [ - { name: 'market_key', label: 'Piyasa', field: 'market_key', 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: 'order_number', label: 'Sipariş No', field: 'order_number', align: 'left', sortable: true }, @@ -1295,8 +1514,8 @@ const idleColumns = [ { 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: 'urun_ilk_grubu', label: 'Ürün İlk Grubu', field: row => row.urun_ilk_grubu || row.yas_grubu || '', 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: 'stock_qty', label: 'Stok', field: row => formatNumber(row.stock_qty, 0), align: 'right', sortable: true }, @@ -1309,10 +1528,10 @@ const idleColumns = [ ] const marketColumns = [ - { name: 'market_key', label: 'Piyasa Grubu', field: 'market_key', align: 'left', sortable: true }, + { name: 'market_key', label: 'Piyasa', field: row => displayMarketName(row.market_key), align: 'left', sortable: true }, { name: 'kategori', label: 'Kategori', field: 'kategori', align: 'left' }, - { name: 'urun_ilk_grubu', label: 'Ürün İlk Grubu', field: row => row.urun_ilk_grubu || row.yas_grubu || '', 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_count', label: 'Ürün', field: row => formatNumber(row.product_count, 0), align: 'right', sortable: true }, @@ -1330,19 +1549,19 @@ const marketColumns = [ const countryColumns = [ { name: 'country', label: 'Ülke', field: 'country', align: 'left', sortable: true }, { name: 'customer_segment', label: 'Müşteri Segmenti', field: 'customer_segment', align: 'left', sortable: true }, - { name: 'market_key', label: 'Piyasa Grubu', field: 'market_key', align: 'left' }, + { name: 'market_key', label: 'Piyasa', field: row => displayMarketName(row.market_key), align: 'left' }, { name: 'product_count', label: 'Ürün', field: row => formatNumber(row.product_count, 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: 'sales_usd_90d', label: '90G USD', field: row => formatMoney(row.sales_usd_90d, 'USD'), align: 'right', sortable: true }, + { name: 'avg_price_usd_90d', label: 'Ort. USD', field: row => formatMoney(row.avg_price_usd_90d, 'USD'), align: 'right', sortable: true }, { name: 'customer_count_90d', label: 'Müşteri', field: row => formatNumber(row.customer_count_90d, 0), align: 'right', sortable: true }, { name: 'invoice_count_90d', label: 'Fatura', field: row => formatNumber(row.invoice_count_90d, 0), align: 'right', sortable: true }, - { name: 'sales_qty_365d', label: '365G Adet', field: row => formatNumber(row.sales_qty_365d, 0), align: 'right', sortable: true }, - { name: 'sales_usd_365d', label: '365G USD', field: row => formatMoney(row.sales_usd_365d, 'USD'), 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 } ] const customerColumns = [ - { name: 'market_key', label: 'Piyasa Grubu', field: 'market_key', align: 'left', sortable: true }, + { name: 'market_key', label: 'Piyasa', field: row => displayMarketName(row.market_key), 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: 'customer_code', label: 'Müşteri Kodu', field: 'customer_code', align: 'left', sortable: true }, @@ -1351,12 +1570,74 @@ 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: '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 }, + { name: 'gross_profit_cost_usd_90d', label: '90G Çıplak K/Z', field: row => formatMoney(row.gross_profit_cost_usd_90d, 'USD'), align: 'right', sortable: true }, + { name: 'gross_margin_base_90d', label: '90G Taban Marj', field: row => formatPercent(row.gross_margin_base_90d), align: 'right', sortable: true }, + { name: 'gross_margin_cost_90d', label: '90G Çıplak Marj', field: row => formatPercent(row.gross_margin_cost_90d), align: 'right', sortable: true }, { name: 'invoice_count_90d', label: 'Fatura', field: row => formatNumber(row.invoice_count_90d, 0), align: 'right', sortable: true }, - { name: 'sales_qty_365d', label: '365G Adet', field: row => formatNumber(row.sales_qty_365d, 0), align: 'right', sortable: true }, - { name: 'sales_usd_365d', label: '365G USD', field: 'sales_usd_365d', 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: 'sales_usd_365d', align: 'right', sortable: true }, { name: 'last_sale_date', label: 'Son Satış', field: 'last_sale_date', align: 'left', sortable: true } ] +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: '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_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: 'product_count', label: 'Ürün Sayısı', field: row => formatNumber(row.product_count, 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: '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: '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: '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: '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_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: '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: '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 }, + { name: 'last_sale_date', label: 'Son Satış', field: 'last_sale_date', align: 'left', sortable: true }, + { name: 'recommendation', label: 'Öneri', field: 'recommendation', align: 'left' } +] + +const visibleSalesBreakdownColumns = computed(() => salesBreakdownColumns.filter(visiblePeriodColumn)) + const summaryCards = computed(() => [ { key: 'date', label: 'KPI Tarihi', value: summary.value.kpi_date || '-' }, { key: 'rows', label: 'Satır', value: formatNumber(summary.value.total_rows, 0) }, @@ -1448,26 +1729,110 @@ const performanceCards = computed(() => { const groupLevels = [ { key: 'kategori', label: 'Kategori' }, - { key: 'urun_ilk_grubu', label: 'Ürün İlk Grubu', fallback: 'yas_grubu' }, { 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' }, - { key: 'color_code', label: 'Renk' } + { key: 'market_key', label: 'Piyasa' }, + { key: 'product_code', label: 'Ürün Kodu' } ] +const tabGroupLevels = { + products: groupLevels, + sales_color_yaka_market_customer: [ + { key: 'color_code', label: 'Renk' }, + { key: 'yaka_kodu', label: 'Yaka' }, + { key: 'market_key', label: 'Piyasa' }, + { key: 'customer_code', label: 'Müşteri' } + ], + idle: [ + { 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' } + ], + sales_market_customer_product: [ + { key: 'market_key', label: 'Piyasa' }, + { key: 'customer_code', label: 'Müşteri' }, + { key: 'product_code', label: 'Ürün Kodu' }, + { 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: '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' } + ], + order_market_details: [ + { key: 'market_key', label: 'Piyasa' }, + { key: 'customer_code', label: 'Müşteri' }, + { key: 'product_code', label: 'Ürün Kodu' }, + { key: 'color_code', label: 'Renk' }, + { key: 'yaka_kodu', label: 'Yaka' } + ] +} + +const activeGroupLevels = computed(() => tabGroupLevels[activeTab.value] || groupLevels) + +const activeSelectedExpandLevelKeys = computed({ + get: () => selectedExpandLevelKeysByTab[activeTab.value] || [], + set: value => { + selectedExpandLevelKeysByTab[activeTab.value] = Array.isArray(value) ? value : [] + } +}) + +const expandLevelOptions = computed(() => activeGroupLevels.value.map(level => ({ + label: level.label, + value: level.key +}))) + +const selectedExpandThroughLevel = computed(() => { + const selected = new Set(activeSelectedExpandLevelKeys.value) + const indexes = activeGroupLevels.value + .map((level, index) => selected.has(level.key) ? index : -1) + .filter(index => index >= 0) + return indexes.length ? Math.max(...indexes) : -1 +}) + +const autoExpandThroughLevel = computed(() => { + const selectedLevel = selectedExpandThroughLevel.value + return selectedLevel >= 0 ? selectedLevel : activeGroupLevels.value.length - 1 +}) + const tableFilterSourceMap = computed(() => ({ - products: { rows: rows.value, columns }, + products: { rows: rows.value, columns: productColumns.value }, general: { rows: generalRows.value, columns: generalColumns }, order_product_customers: { rows: orderProductCustomerRows.value, columns: orderProductCustomerColumns }, order_market_details: { rows: orderMarketDetailRows.value, columns: orderMarketDetailColumns }, idle: { rows: idleRows.value, columns: idleColumns }, markets: { rows: marketRows.value, columns: marketColumns }, countries: { rows: countryRows.value, columns: countryColumns }, - customers: { rows: customerRows.value, columns: customerColumns } + customers: { rows: customerRows.value, columns: customerColumns }, + sales_color_yaka_market_customer: { rows: salesBreakdownRows.sales_color_yaka_market_customer, columns: visibleSalesBreakdownColumns.value }, + sales_product_country_segment_market_customer: { rows: salesBreakdownRows.sales_product_country_segment_market_customer, columns: visibleSalesBreakdownColumns.value }, + sales_market_customer_product: { rows: salesBreakdownRows.sales_market_customer_product, columns: visibleSalesBreakdownColumns.value }, + sales_country_segment_market_customer_product: { rows: salesBreakdownRows.sales_country_segment_market_customer_product, columns: visibleSalesBreakdownColumns.value } })) -const filteredProductRows = computed(() => filterRowsForTable('products', rows.value, columns)) +const filteredProductRows = computed(() => filterRowsForTable('products', rows.value, productColumns.value)) const filteredGeneralRows = computed(() => filterRowsForTable('general', generalRows.value, generalColumns)) const filteredOrderProductCustomerRows = computed(() => filterRowsForTable('order_product_customers', orderProductCustomerRows.value, orderProductCustomerColumns)) const filteredOrderMarketDetailRows = computed(() => filterRowsForTable('order_market_details', orderMarketDetailRows.value, orderMarketDetailColumns)) @@ -1475,6 +1840,7 @@ const filteredIdleRows = computed(() => filterRowsForTable('idle', idleRows.valu const filteredMarketRows = computed(() => filterRowsForTable('markets', marketRows.value, marketColumns)) const filteredCountryRows = computed(() => filterRowsForTable('countries', countryRows.value, countryColumns)) const filteredCustomerRows = computed(() => filterRowsForTable('customers', customerRows.value, customerColumns)) +const filteredActiveSalesBreakdownRows = computed(() => filterRowsForTable(activeTab.value, salesBreakdownRows[activeTab.value] || [], visibleSalesBreakdownColumns.value)) const columnFilterOptionMap = computed(() => { const out = {} @@ -1502,17 +1868,34 @@ const columnFilterOptionMap = computed(() => { const productTableRows = computed(() => { const out = [] - appendGroupRows(out, filteredProductRows.value, 0, []) + appendGroupRows(out, filteredProductRows.value, 0, [], groupLevels) return out }) -function appendGroupRows (out, sourceRows, level, parentKeys) { - if (level >= groupLevels.length) { - out.push(...sourceRows) +const productGroupKeys = computed(() => { + const keys = [] + collectGroupKeys(keys, filteredProductRows.value, 0, [], groupLevels) + return keys +}) + +const productAutoExpandKeys = computed(() => { + const keys = [] + collectGroupKeys(keys, filteredProductRows.value, 0, [], activeGroupLevels.value, autoExpandThroughLevel.value) + return keys +}) + +const allProductGroupsExpanded = computed(() => { + const keys = productAutoExpandKeys.value + return keys.length > 0 && keys.every(key => expandedGroups.value[key] === true) +}) + +function appendGroupRows (out, sourceRows, level, parentKeys, levels = groupLevels) { + if (level >= levels.length) { + out.push(...sortProductLeafRows(sourceRows)) return } - const groupDef = groupLevels[level] + const groupDef = levels[level] const grouped = new Map() for (const row of sourceRows) { const value = normalizeGroupValue(rawProductCellValue(row, groupDef.key) || row[groupDef.fallback]) @@ -1526,12 +1909,45 @@ function appendGroupRows (out, sourceRows, level, parentKeys) { const key = [...parentKeys, `${groupDef.key}:${value}`].join('|') out.push(makeGroupRow(key, level, groupDef, value, groupRows)) if (isGroupExpanded(key)) { - appendGroupRows(out, groupRows, level + 1, [...parentKeys, `${groupDef.key}:${value}`]) + appendGroupRows(out, groupRows, level + 1, [...parentKeys, `${groupDef.key}:${value}`], levels) } }) } +function collectGroupKeys (out, sourceRows, level, parentKeys, levels = groupLevels, maxLevel = levels.length - 1) { + if (level >= levels.length) return + + const groupDef = levels[level] + const grouped = new Map() + for (const row of sourceRows) { + const value = normalizeGroupValue(rawProductCellValue(row, groupDef.key) || row[groupDef.fallback]) + if (!grouped.has(value)) grouped.set(value, []) + grouped.get(value).push(row) + } + + Array.from(grouped.entries()) + .sort((a, b) => a[0].localeCompare(b[0], 'tr')) + .forEach(([value, groupRows]) => { + const key = [...parentKeys, `${groupDef.key}:${value}`].join('|') + out.push(key) + if (level < maxLevel) { + collectGroupKeys(out, groupRows, level + 1, [...parentKeys, `${groupDef.key}:${value}`], levels, maxLevel) + } + }) +} + +function sortProductLeafRows (sourceRows) { + return [...sourceRows].sort((a, b) => { + const colorCmp = String(a?.color_code || '').localeCompare(String(b?.color_code || ''), 'tr', { numeric: true }) + if (colorCmp !== 0) return colorCmp + const yakaCmp = String(a?.yaka_kodu || '').localeCompare(String(b?.yaka_kodu || ''), 'tr', { numeric: true }) + if (yakaCmp !== 0) return yakaCmp + return String(a?.item_description || '').localeCompare(String(b?.item_description || ''), 'tr', { numeric: true }) + }) +} + function makeGroupRow (key, level, groupDef, value, groupRows) { + const imageSource = groupRows.find(row => row?.product_code) || {} return { __group: true, row_key: `group|${key}`, @@ -1539,8 +1955,11 @@ function makeGroupRow (key, level, groupDef, value, groupRows) { level, group_field: groupDef.key, group_value: value, - label: `${groupDef.label}: ${value}`, + label: value, count: groupRows.length, + image_product_code: imageSource.product_code || '', + image_color_code: imageSource.color_code || '', + image_yaka_kodu: imageSource.yaka_kodu || '', product_code: groupDef.key === 'product_code' ? value : distinctSummary(groupRows, 'product_code'), color_code: groupDef.key === 'color_code' ? value : distinctSummary(groupRows, 'color_code'), yaka_kodu: groupDef.key === 'yaka_kodu' ? value : distinctSummary(groupRows, 'yaka_kodu'), @@ -1550,33 +1969,48 @@ function makeGroupRow (key, level, groupDef, value, groupRows) { askili_yan: groupDef.key === 'askili_yan' ? value : distinctSummary(groupRows, 'askili_yan'), 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: distinctSummary(groupRows, 'market_key'), + market_key: groupDef.key === 'market_key' ? value : distinctSummary(groupRows, 'market_key'), stock_qty: sumRows(groupRows, 'stock_qty'), sales_qty_90d: sumRows(groupRows, 'sales_qty_90d'), sales_qty_180d: sumRows(groupRows, 'sales_qty_180d'), + sales_qty_365d: sumRows(groupRows, 'sales_qty_365d'), + sales_qty_total: sumRows(groupRows, 'sales_qty_total'), sales_usd_90d: sumRows(groupRows, 'sales_usd_90d'), sales_usd_180d: sumRows(groupRows, 'sales_usd_180d'), + sales_usd_365d: sumRows(groupRows, 'sales_usd_365d'), + sales_usd_total: sumRows(groupRows, 'sales_usd_total'), gross_profit_usd_90d: sumRows(groupRows, 'gross_profit_usd_90d'), gross_profit_usd_180d: sumRows(groupRows, 'gross_profit_usd_180d'), + gross_profit_usd_total: sumRows(groupRows, 'gross_profit_usd_total'), market_count_90d: distinctCount(groupRows, 'market_key'), customer_count_90d: sumRows(groupRows, 'customer_count_90d'), + market_count_total: sumRows(groupRows, 'market_count_total'), + customer_count_total: sumRows(groupRows, 'customer_count_total'), avg_price_usd_90d: weightedAverage(groupRows, 'sales_usd_90d', 'sales_qty_90d'), avg_price_usd_180d: weightedAverage(groupRows, 'sales_usd_180d', 'sales_qty_180d'), + avg_price_usd_365d: weightedAverage(groupRows, 'sales_usd_365d', 'sales_qty_365d'), + avg_price_usd_total: weightedAverage(groupRows, 'sales_usd_total', 'sales_qty_total'), base_price_usd: weightedAverageOrAverage(groupRows, 'base_price_usd', 'sales_qty_90d'), cost_price_usd: weightedAverageOrAverage(groupRows, 'cost_price_usd', 'sales_qty_90d'), unit_profit_base_90d: weightedAverageOrAverage(groupRows, 'unit_profit_base_90d', 'sales_qty_90d'), unit_profit_cost_90d: weightedAverageOrAverage(groupRows, 'unit_profit_cost_90d', 'sales_qty_90d'), unit_profit_base_180d: weightedAverageOrAverage(groupRows, 'unit_profit_base_180d', 'sales_qty_180d'), unit_profit_cost_180d: weightedAverageOrAverage(groupRows, 'unit_profit_cost_180d', 'sales_qty_180d'), + unit_profit_base_total: weightedAverageOrAverage(groupRows, 'unit_profit_base_total', 'sales_qty_total'), + unit_profit_cost_total: weightedAverageOrAverage(groupRows, 'unit_profit_cost_total', 'sales_qty_total'), gross_margin_base_90d: marginFromRows(groupRows, 'sales_usd_90d', 'sales_qty_90d', 'base_price_usd'), gross_margin_cost_90d: marginFromRows(groupRows, 'sales_usd_90d', 'sales_qty_90d', 'cost_price_usd'), gross_margin_base_180d: marginFromRows(groupRows, 'sales_usd_180d', 'sales_qty_180d', 'base_price_usd'), gross_margin_cost_180d: marginFromRows(groupRows, 'sales_usd_180d', 'sales_qty_180d', 'cost_price_usd'), + gross_margin_base_total: marginFromRows(groupRows, 'sales_usd_total', 'sales_qty_total', 'base_price_usd'), + gross_margin_cost_total: marginFromRows(groupRows, 'sales_usd_total', 'sales_qty_total', 'cost_price_usd'), gross_margin_90d: ratio(sumRows(groupRows, 'gross_profit_usd_90d'), sumRows(groupRows, 'sales_usd_90d')), gross_margin_180d: ratio(sumRows(groupRows, 'gross_profit_usd_180d'), sumRows(groupRows, 'sales_usd_180d')), stock_days_90d: weightedAverageOrAverage(groupRows, 'stock_days_90d', 'stock_qty'), stock_days_180d: weightedAverageOrAverage(groupRows, 'stock_days_180d', 'stock_qty'), + 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'), recommendation: `${formatNumber(groupRows.length, 0)} satır` @@ -1688,12 +2122,33 @@ function toggleGroup (key) { } } +function toggleAllProductGroups () { + const keys = productAutoExpandKeys.value + if (allProductGroupsExpanded.value) { + expandedGroups.value = {} + return + } + expandSelectedProductGroups() +} + +function expandSelectedProductGroups () { + const keys = productAutoExpandKeys.value + const next = { ...expandedGroups.value } + for (const key of keys) next[key] = true + expandedGroups.value = next +} + function groupLabelColumnName (row) { return row?.group_field || 'product_code' } +function groupShowsImage (row) { + return Number(row?.level ?? 0) < autoExpandThroughLevel.value +} + function groupCellClass (name) { - return ['stock_qty', 'sales_qty_90d', 'sales_qty_180d', 'sales_usd_90d', 'sales_usd_180d', 'stock_days_90d', 'stock_days_180d', 'avg_price_usd_90d', 'avg_price_usd_180d', 'base_price_usd', 'cost_price_usd', 'unit_profit_base_90d', 'unit_profit_cost_90d', 'unit_profit_base_180d', 'unit_profit_cost_180d', 'gross_profit_usd_90d', 'gross_profit_usd_180d', 'gross_margin_base_90d', 'gross_margin_cost_90d', 'gross_margin_base_180d', 'gross_margin_cost_180d', 'gross_margin_90d', 'gross_margin_180d', 'market_count_90d', 'customer_count_90d', 'sales_index_90d', 'performance_score'].includes(name) + if (name === 'image') return 'product-image-cell' + 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', '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) ? 'text-right' : '' } @@ -1707,6 +2162,7 @@ function formatGroupCell (row, name) { function withProductMargins (row) { return { ...row, + avg_price_usd_365d: Number(row?.sales_qty_365d || 0) > 0 ? Number(row?.sales_usd_365d || 0) / Number(row?.sales_qty_365d || 0) : 0, gross_margin_base_90d: marginFromSalesCost(row?.sales_usd_90d, row?.sales_qty_90d, row?.base_price_usd), gross_margin_cost_90d: marginFromSalesCost(row?.sales_usd_90d, row?.sales_qty_90d, row?.cost_price_usd), gross_margin_base_180d: marginFromSalesCost(row?.sales_usd_180d, row?.sales_qty_180d, row?.base_price_usd), @@ -1793,6 +2249,7 @@ function rawProductCellValue (row, name) { switch (name) { case 'urun_ilk_grubu': return row.urun_ilk_grubu || row.yas_grubu || '' case 'urun_ana_grubu': return row.urun_ana_grubu || row.seri || '' + case 'market_key': return displayMarketName(row.market_key) case 'performance_bucket': return bucketLabel(row.performance_bucket) default: return row[name] } @@ -1803,6 +2260,7 @@ function rawColumnCellValue (row, col) { switch (name) { case 'urun_ilk_grubu': return row.urun_ilk_grubu || row.yas_grubu || '' case 'urun_ana_grubu': return row.urun_ana_grubu || row.seri || '' + case 'market_key': return displayMarketName(row.market_key) case 'performance_bucket': return bucketLabel(row.performance_bucket) case 'is_overdue': return row.is_overdue ? 'Evet' : 'Hayır' default: @@ -1823,26 +2281,39 @@ function formatProductCell (row, name) { case 'stock_qty': case 'sales_qty_90d': case 'sales_qty_180d': + case 'sales_qty_365d': + case 'sales_qty_total': case 'market_count_90d': case 'customer_count_90d': + case 'market_count_total': + case 'customer_count_total': return formatNumber(row[name], 0) case 'stock_days_90d': case 'stock_days_180d': + case 'stock_days_total': case 'sales_index_90d': + case 'sales_index_total': case 'performance_score': return formatNumber(row[name], name === 'performance_score' ? 1 : 2) case 'sales_usd_90d': case 'sales_usd_180d': + case 'sales_usd_365d': + case 'sales_usd_total': case 'avg_price_usd_90d': case 'avg_price_usd_180d': + case 'avg_price_usd_365d': + case 'avg_price_usd_total': case 'base_price_usd': case 'cost_price_usd': case 'unit_profit_base_90d': case 'unit_profit_cost_90d': case 'unit_profit_base_180d': case 'unit_profit_cost_180d': + case 'unit_profit_base_total': + case 'unit_profit_cost_total': case 'gross_profit_usd_90d': case 'gross_profit_usd_180d': + case 'gross_profit_usd_total': return formatMoney(row[name], 'USD') case 'gross_margin_base_90d': case 'gross_margin_cost_90d': @@ -1857,6 +2328,8 @@ function formatProductCell (row, name) { return row.urun_ilk_grubu || row.yas_grubu || '' case 'urun_ana_grubu': return row.urun_ana_grubu || row.seri || '' + case 'market_key': + return displayMarketName(row.market_key) default: return rawProductCellValue(row, name) || '' } @@ -1873,6 +2346,35 @@ function normalizeRow (row) { }) } +function performanceVariantKey (row) { + return [ + String(row?.product_code || '').trim(), + String(row?.color_code || '').trim(), + String(row?.yaka_kodu || '').trim(), + displayMarketName(row?.market_key) + ].join('|') +} + +function generalMetricFields (row) { + return { + period_start: row?.period_start || '', + period_end: row?.period_end || '', + sales_qty_total: Number(row?.sales_qty_total || 0), + sales_usd_total: Number(row?.sales_usd_total || 0), + avg_daily_sales_total: Number(row?.avg_daily_sales_total || 0), + stock_days_total: Number(row?.stock_days_total || 0), + avg_price_usd_total: Number(row?.avg_price_usd_total || 0), + gross_profit_usd_total: Number(row?.gross_profit_usd_total || 0), + gross_margin_total: Number(row?.gross_margin_total || 0), + unit_profit_cost_total: Number(row?.unit_profit_cost_total || 0), + unit_profit_base_total: Number(row?.unit_profit_base_total || 0), + market_count_total: Number(row?.market_count_total || 0), + customer_count_total: Number(row?.customer_count_total || 0), + invoice_count_total: Number(row?.invoice_count_total || 0), + sales_index_total: Number(row?.sales_index_total || 0) + } +} + function normalizeGeneralRow (row) { const product = String(row?.product_code || '').trim() const color = String(row?.color_code || '').trim() @@ -1923,6 +2425,13 @@ function normalizeOrderMarketDetailRow (row) { } } +function normalizeSalesBreakdownRow (row) { + return { + ...row, + 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 || ''}` + } +} + function orderGroupLabel (row) { if (row?.breakdown === 'customer') { return [row.customer_code, row.customer_name].filter(Boolean).join(' - ') || '-' @@ -1931,12 +2440,17 @@ function orderGroupLabel (row) { } function productImageKey (row) { - const product = String(row?.product_code || '').trim() - const color = String(row?.color_code || '').trim() - const yaka = String(row?.yaka_kodu || '').trim() + const product = String(row?.image_product_code || row?.product_code || '').trim() + const color = String(row?.image_color_code || row?.color_code || '').trim() + const yaka = String(row?.image_yaka_kodu || row?.yaka_kodu || '').trim() return `${product}|${color}|${yaka}` } +function displayMarketName (value) { + const parts = String(value || '').split('|').map(part => part.trim()).filter(Boolean) + return parts.length ? parts[parts.length - 1] : '' +} + function normalizeUploadsPath (storagePath) { const raw = String(storagePath || '').trim() if (!raw) return '' @@ -1976,11 +2490,11 @@ async function fetchProductImagesForRow (row) { return imageListByKey.value[key] } - const code = String(row?.product_code || '').trim() + const code = String(row?.image_product_code || row?.product_code || '').trim() if (!code) return [] - const color = String(row?.color_code || '').trim() - const yaka = String(row?.yaka_kodu || '').trim() + const color = String(row?.image_color_code || row?.color_code || '').trim() + const yaka = String(row?.image_yaka_kodu || row?.yaka_kodu || '').trim() try { const resp = await api.get('/product-images', { @@ -2024,11 +2538,11 @@ async function openProductImageDialog (row) { const urls = await fetchProductImagesForRow(row) imageDialogUrls.value = urls imageSlide.value = 0 - imageDialogTitle.value = String(row?.product_code || '-') + imageDialogTitle.value = String(row?.image_product_code || row?.product_code || '-') imageDialogSubtitle.value = [ row?.item_description, - row?.color_code ? `Renk: ${row.color_code}` : '', - row?.yaka_kodu ? `Yaka: ${row.yaka_kodu}` : '' + row?.image_color_code || row?.color_code, + row?.image_yaka_kodu || row?.yaka_kodu ].filter(Boolean).join(' | ') imageDialog.value = true } @@ -2038,9 +2552,9 @@ async function openPerformanceDialog (row) { performanceDialogTitle.value = String(row?.product_code || '-') performanceDialogSubtitle.value = [ row?.item_description, - row?.color_code ? `Renk: ${row.color_code}` : '', - row?.yaka_kodu ? `Yaka: ${row.yaka_kodu}` : '', - row?.market_key ? `Piyasa: ${row.market_key}` : '' + row?.color_code, + row?.yaka_kodu, + row?.market_key ].filter(Boolean).join(' | ') performanceDialog.value = true detailLoading.value = true @@ -2071,7 +2585,13 @@ async function openPerformanceDialog (row) { async function reload () { loading.value = true try { - const [summaryResp, listResp, generalResp, marketResp, countryResp, customerResp] = await Promise.all([ + const salesBreakdownRequests = salesBreakdownTabKeys.map(tabKey => { + return api.get('/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', { params: { @@ -2088,11 +2608,17 @@ async function reload () { api.get('/pricing/product-performance/customers', { params: { limit: 500, breakdown: customerBreakdown.value }, timeout: 60000 - }) + }), + ...salesBreakdownRequests ]) summary.value = summaryResp?.data || {} - rows.value = (Array.isArray(listResp?.data?.rows) ? listResp.data.rows : []).map(normalizeRow) - generalRows.value = (Array.isArray(generalResp?.data) ? generalResp.data : []).map(normalizeGeneralRow) + const normalizedGeneralRows = (Array.isArray(generalResp?.data) ? generalResp.data : []).map(normalizeGeneralRow) + const generalByVariant = new Map(normalizedGeneralRows.map(row => [performanceVariantKey(row), generalMetricFields(row)])) + rows.value = (Array.isArray(listResp?.data?.rows) ? listResp.data.rows : []).map(row => { + const generalMetrics = generalByVariant.get(performanceVariantKey(row)) || {} + return normalizeRow({ ...row, ...generalMetrics }) + }) + generalRows.value = normalizedGeneralRows marketRows.value = Array.isArray(marketResp?.data) ? marketResp.data : [] countryRows.value = (Array.isArray(countryResp?.data) ? countryResp.data : []).map(row => ({ ...row, @@ -2102,6 +2628,9 @@ async function reload () { ...row, customer_key: `${row.breakdown || '-'}|${row.market_key || '-'}|${row.country || '-'}|${row.customer_segment || '-'}|${row.customer_code || '-'}` })) + salesBreakdownTabKeys.forEach((tabKey, index) => { + salesBreakdownRows[tabKey] = (Array.isArray(salesBreakdownResp[index]?.data) ? salesBreakdownResp[index].data : []).map(normalizeSalesBreakdownRow) + }) pagination.value.rowsNumber = Number(listResp?.data?.total_count || 0) if (activeTab.value === 'order_product_customers') { await loadOrderProductCustomers(false) @@ -2109,7 +2638,7 @@ async function reload () { if (activeTab.value === 'order_market_details') { await loadOrderMarketDetails(false) } - void primeProductImages([...rows.value, ...generalRows.value]) + void primeProductImages(rows.value) } catch (err) { Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Ürün performans verisi alınamadı' }) } finally { @@ -2180,6 +2709,7 @@ function bucketColor (value) { case 'STOK_RISKI': return 'orange-8' case 'FIYAT_BASKISI': return 'red-7' case 'FIYAT_FIRSATI': return 'teal-7' + case 'MALIYET_YOK': return 'blue-grey-7' default: return 'grey-7' } } @@ -2193,6 +2723,14 @@ watch(activeTab, tab => { } }) +watch(productTableRows, tableRows => { + if (activeTab.value !== 'products') return + const imageRows = tableRows + .filter(row => !row.__group || groupShowsImage(row)) + .slice(0, 240) + void primeProductImages(imageRows) +}, { immediate: true }) + onMounted(reload) @@ -2212,6 +2750,11 @@ onMounted(reload) margin-top: 4px; } +.detail-level-menu { + width: 260px; + padding: 10px; +} + .performance-table { max-height: calc(100vh - 260px); } @@ -2229,6 +2772,20 @@ onMounted(reload) .performance-table :deep(.q-table td) { border-right: 1px solid #d5dce5; border-bottom: 1px solid #d5dce5; + vertical-align: middle; + white-space: normal; +} + +.performance-table :deep(.q-table tbody td) { + max-width: 220px; +} + +.performance-table :deep(.q-table tbody td span:not(.q-badge):not(.q-icon)) { + display: -webkit-box; + -webkit-line-clamp: 4; + -webkit-box-orient: vertical; + overflow: hidden; + line-height: 1.25; } .performance-table :deep(.q-table th:first-child), @@ -2244,6 +2801,113 @@ onMounted(reload) border-top: 1px solid #d5dce5; } +.product-breakdown-table :deep(.q-table) { + min-width: 2050px; + table-layout: fixed; +} + +.product-breakdown-table :deep(.q-table th:nth-child(-n+10)), +.product-breakdown-table :deep(.q-table td:nth-child(-n+10)) { + position: sticky; + z-index: 2; +} + +.product-breakdown-table :deep(.q-table th:nth-child(-n+10)) { + z-index: 4; + background: #f8fbff; +} + +.product-breakdown-table :deep(.q-table tbody tr:not(.group-row) td:nth-child(-n+10)) { + background: #fff; +} + +.product-breakdown-table :deep(.q-table th:nth-child(1)), +.product-breakdown-table :deep(.q-table td:nth-child(1)) { + left: 0; + width: 80px; + min-width: 80px; + max-width: 80px; +} + +.product-breakdown-table :deep(.q-table th:nth-child(2)), +.product-breakdown-table :deep(.q-table td:nth-child(2)) { + left: 80px; + width: 96px; + min-width: 96px; + max-width: 96px; +} + +.product-breakdown-table :deep(.q-table th:nth-child(3)), +.product-breakdown-table :deep(.q-table td:nth-child(3)) { + left: 176px; + width: 48px; + min-width: 48px; + max-width: 48px; +} + +.product-breakdown-table :deep(.q-table th:nth-child(4)), +.product-breakdown-table :deep(.q-table td:nth-child(4)) { + left: 224px; + width: 48px; + min-width: 48px; + max-width: 48px; +} + +.product-breakdown-table :deep(.q-table th:nth-child(5)), +.product-breakdown-table :deep(.q-table td:nth-child(5)) { + left: 272px; + width: 150px; + min-width: 150px; + max-width: 150px; +} + +.product-breakdown-table :deep(.q-table th:nth-child(6)), +.product-breakdown-table :deep(.q-table td:nth-child(6)) { + left: 422px; + width: 72px; + min-width: 72px; + max-width: 72px; +} + +.product-breakdown-table :deep(.q-table th:nth-child(7)), +.product-breakdown-table :deep(.q-table td:nth-child(7)) { + left: 494px; + width: 72px; + min-width: 72px; + max-width: 72px; +} + +.product-breakdown-table :deep(.q-table th:nth-child(8)), +.product-breakdown-table :deep(.q-table td:nth-child(8)) { + left: 566px; + width: 92px; + min-width: 92px; + max-width: 92px; +} + +.product-breakdown-table :deep(.q-table th:nth-child(9)), +.product-breakdown-table :deep(.q-table td:nth-child(9)) { + left: 658px; + width: 96px; + min-width: 96px; + max-width: 96px; +} + +.product-breakdown-table :deep(.q-table th:nth-child(10)), +.product-breakdown-table :deep(.q-table td:nth-child(10)) { + left: 754px; + width: 96px; + min-width: 96px; + max-width: 96px; + box-shadow: 8px 0 10px -10px rgba(17, 24, 39, 0.45); +} + +.product-breakdown-table :deep(.q-table th:nth-child(11)), +.product-breakdown-table :deep(.q-table td:nth-child(11)) { + width: 180px; + min-width: 140px; +} + .filterable-header-cell { vertical-align: middle; } @@ -2253,7 +2917,7 @@ onMounted(reload) grid-template-columns: minmax(0, 1fr) 20px; align-items: center; gap: 2px; - min-width: 78px; + min-width: 42px; } .header-with-filter > span { @@ -2305,47 +2969,47 @@ onMounted(reload) } .group-row td { - background: #eaf4ff; + background: color-mix(in srgb, var(--q-primary) 8%, #ffffff); font-weight: 600; - color: #17324f; - border-color: #c3d4e6; + color: color-mix(in srgb, var(--q-primary) 55%, #1f2937); + border-color: color-mix(in srgb, var(--q-primary) 18%, #d5dce5); white-space: nowrap; } .group-row-level-0 td { - background: #1565c0; + background: color-mix(in srgb, var(--q-primary) 82%, #1f2937); color: #fff; - border-color: #0d47a1; + border-color: color-mix(in srgb, var(--q-primary) 75%, #111827); } .group-row-level-1 td { - background: #2f7fd0; + background: color-mix(in srgb, var(--q-primary) 68%, #ffffff); color: #fff; - border-color: #1565c0; + border-color: color-mix(in srgb, var(--q-primary) 58%, #d5dce5); } .group-row-level-2 td { - background: #6da9df; - color: #0c2f52; - border-color: #3c83c7; + background: color-mix(in srgb, var(--q-primary) 36%, #ffffff); + color: color-mix(in srgb, var(--q-primary) 65%, #111827); + border-color: color-mix(in srgb, var(--q-primary) 34%, #d5dce5); } .group-row-level-3 td { - background: #a9cdef; - color: #12385c; - border-color: #78addb; + background: color-mix(in srgb, var(--q-primary) 22%, #ffffff); + color: color-mix(in srgb, var(--q-primary) 58%, #1f2937); + border-color: color-mix(in srgb, var(--q-primary) 24%, #d5dce5); } .group-row-level-4 td { - background: #d1e5f8; - color: #17324f; - border-color: #9fc5e8; + background: color-mix(in srgb, var(--q-primary) 14%, #ffffff); + color: color-mix(in srgb, var(--q-primary) 52%, #1f2937); + border-color: color-mix(in srgb, var(--q-primary) 18%, #d5dce5); } .group-row-level-5 td { - background: #eaf4ff; - color: #17324f; - border-color: #c3d4e6; + background: color-mix(in srgb, var(--q-primary) 8%, #ffffff); + color: color-mix(in srgb, var(--q-primary) 48%, #1f2937); + border-color: color-mix(in srgb, var(--q-primary) 14%, #d5dce5); } .group-cell-label { @@ -2360,24 +3024,27 @@ onMounted(reload) } .product-image-cell { - width: 72px; - min-width: 72px; + width: 80px; + min-width: 80px; } .product-thumb-button { - width: 56px; - height: 72px; + width: 68px; + height: 92px; border: 1px solid #d9dde3; border-radius: 6px; background: #fff; - padding: 2px; + padding: 4px; cursor: pointer; + display: flex; + align-items: center; + justify-content: center; } .product-thumb, .product-thumb-placeholder { - width: 50px; - height: 66px; + width: 58px; + height: 82px; } .product-thumb-placeholder {