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 @@