diff --git a/svc/queries/product_performance.go b/svc/queries/product_performance.go index ec638b2..db172e8 100644 --- a/svc/queries/product_performance.go +++ b/svc/queries/product_performance.go @@ -2600,6 +2600,10 @@ func ListProductPerformanceGrouped(ctx context.Context, pg *sql.DB, req ProductP levels = defaultProductPerformanceGroupLevels(req.Mode) } + if out, ok, err := listProductPerformanceGroupedSQL(ctx, pg, req, levels); ok || err != nil { + return out, err + } + sourceRows, err := productPerformanceGroupedSourceRows(ctx, pg, req.Mode, req.Limit) if err != nil { return nil, err @@ -2609,6 +2613,302 @@ func ListProductPerformanceGrouped(ctx context.Context, pg *sql.DB, req ProductP return out, nil } +type productPerformanceSQLGroupFilter struct { + Field string + Value string +} + +func listProductPerformanceGroupedSQL(ctx context.Context, pg *sql.DB, req ProductPerformanceGroupedRequest, levels []string) ([]map[string]any, bool, error) { + mode := strings.TrimSpace(req.Mode) + if mode != "products" && mode != "idle" { + return nil, false, nil + } + if err := EnsureProductPerformanceTables(pg); err != nil { + return nil, true, err + } + out := make([]map[string]any, 0, 512) + err := appendProductPerformanceGroupedSQLRows(ctx, pg, &out, mode, levels, 0, []string{"tab:" + mode}, nil, req.ExpandedKeys, req.Limit) + return out, true, err +} + +func appendProductPerformanceGroupedSQLRows(ctx context.Context, pg *sql.DB, out *[]map[string]any, mode string, levels []string, level int, parentKeys []string, filters []productPerformanceSQLGroupFilter, expandedKeys map[string]bool, limit int) error { + if level >= len(levels) { + rows, err := queryProductPerformanceSQLLeafRows(ctx, pg, mode, filters, limit) + if err != nil { + return err + } + *out = append(*out, rows...) + return nil + } + + field := levels[level] + rows, err := queryProductPerformanceSQLGroupRows(ctx, pg, mode, field, level, parentKeys, filters) + if err != nil { + return err + } + for _, row := range rows { + value := stringFromMap(row, "group_value") + keyPart := field + ":" + value + key := strings.Join(append(parentKeys, keyPart), "|") + row["row_key"] = "group|" + key + row["key"] = key + row["level"] = level + row["group_field"] = field + row["group_value"] = value + row["label"] = value + row["__group"] = true + row[field] = value + *out = append(*out, row) + if expandedKeys[key] { + nextFilters := append(append([]productPerformanceSQLGroupFilter{}, filters...), productPerformanceSQLGroupFilter{Field: field, Value: value}) + if err := appendProductPerformanceGroupedSQLRows(ctx, pg, out, mode, levels, level+1, append(parentKeys, keyPart), nextFilters, expandedKeys, limit); err != nil { + return err + } + } + } + return nil +} + +func queryProductPerformanceSQLGroupRows(ctx context.Context, pg *sql.DB, mode, field string, level int, parentKeys []string, filters []productPerformanceSQLGroupFilter) ([]map[string]any, error) { + groupExpr, ok := productPerformanceSQLGroupExpr(field) + if !ok { + return nil, fmt.Errorf("unsupported product performance group field: %s", field) + } + whereSQL, args, err := productPerformanceSQLFilterWhere(filters) + if err != nil { + return nil, err + } + query := productPerformanceSQLSourceCTE(mode) + fmt.Sprintf(` +SELECT jsonb_build_object( + 'group_value', group_value, + 'label', group_value, + 'count', row_count, + 'recommendation', row_count::text || ' satir', + 'image_product_code', image_product_code, + 'image_color_code', image_color_code, + 'image_yaka_kodu', image_yaka_kodu, + 'product_code', CASE WHEN $%d = 'product_code' THEN group_value ELSE product_code END, + 'color_code', CASE WHEN $%d = 'color_code' THEN group_value ELSE color_code END, + 'yaka_kodu', CASE WHEN $%d = 'yaka_kodu' THEN group_value ELSE yaka_kodu END, + 'item_description', item_description, + 'kategori', CASE WHEN $%d = 'kategori' THEN group_value ELSE kategori END, + 'askili_yan', CASE WHEN $%d = 'askili_yan' THEN group_value ELSE askili_yan END, + 'urun_ilk_grubu', CASE WHEN $%d = 'urun_ilk_grubu' THEN group_value ELSE urun_ilk_grubu END, + 'urun_ana_grubu', CASE WHEN $%d = 'urun_ana_grubu' THEN group_value ELSE urun_ana_grubu END, + 'urun_alt_grubu', CASE WHEN $%d = 'urun_alt_grubu' THEN group_value ELSE urun_alt_grubu END, + 'market_key', CASE WHEN $%d = 'market_key' THEN group_value ELSE market_key END, + 'stock_qty', stock_qty, + 'sales_qty_90d', sales_qty_90d, + 'sales_qty_180d', sales_qty_180d, + 'sales_qty_365d', sales_qty_365d, + 'sales_usd_90d', sales_usd_90d, + 'sales_usd_180d', sales_usd_180d, + 'sales_usd_365d', sales_usd_365d, + 'avg_price_usd_90d', avg_price_usd_90d, + 'avg_price_usd_180d', avg_price_usd_180d, + 'cost_price_usd', cost_price_usd, + 'base_price_usd', base_price_usd, + 'base_price_try', base_price_try, + 'gross_profit_usd_90d', gross_profit_usd_90d, + 'gross_profit_usd_180d', gross_profit_usd_180d, + 'gross_margin_90d', gross_margin_90d, + 'gross_margin_180d', gross_margin_180d, + 'unit_profit_cost_90d', unit_profit_cost_90d, + 'unit_profit_cost_180d', unit_profit_cost_180d, + 'unit_profit_base_90d', unit_profit_base_90d, + 'unit_profit_base_180d', unit_profit_base_180d, + 'market_count_90d', market_count_90d, + 'customer_count_90d', customer_count_90d, + 'sales_index_90d', sales_index_90d, + 'price_index_90d', price_index_90d, + 'margin_index_90d', margin_index_90d, + 'performance_score', performance_score, + 'performance_bucket', performance_bucket, + 'idle_cost_usd', idle_cost_usd, + 'stock_turnover_90d', CASE WHEN stock_qty > 0 THEN sales_qty_90d / NULLIF(stock_qty,0) ELSE 0 END, + 'stock_turnover_180d', CASE WHEN stock_qty > 0 THEN sales_qty_180d / NULLIF(stock_qty,0) ELSE 0 END, + 'stock_turnover_365d', CASE WHEN stock_qty > 0 THEN sales_qty_365d / NULLIF(stock_qty,0) ELSE 0 END +) AS row_json +FROM ( + SELECT + COALESCE(NULLIF(%s,''), '-') AS group_value, + COUNT(*)::integer AS row_count, + (ARRAY_AGG(product_code ORDER BY performance_score DESC NULLS LAST))[1] AS image_product_code, + (ARRAY_AGG(color_code ORDER BY performance_score DESC NULLS LAST))[1] AS image_color_code, + (ARRAY_AGG(yaka_kodu ORDER BY performance_score DESC NULLS LAST))[1] AS image_yaka_kodu, + COALESCE((ARRAY_AGG(NULLIF(product_code,'') ORDER BY performance_score DESC NULLS LAST))[1], '') AS product_code, + COALESCE((ARRAY_AGG(NULLIF(color_code,'') ORDER BY performance_score DESC NULLS LAST))[1], '') AS color_code, + COALESCE((ARRAY_AGG(NULLIF(yaka_kodu,'') ORDER BY performance_score DESC NULLS LAST))[1], '') AS yaka_kodu, + COALESCE((ARRAY_AGG(NULLIF(item_description,'') ORDER BY performance_score DESC NULLS LAST))[1], '') AS item_description, + COALESCE((ARRAY_AGG(NULLIF(kategori,'') ORDER BY performance_score DESC NULLS LAST))[1], '') AS kategori, + COALESCE((ARRAY_AGG(NULLIF(askili_yan,'') ORDER BY performance_score DESC NULLS LAST))[1], '') AS askili_yan, + COALESCE((ARRAY_AGG(NULLIF(urun_ilk_grubu,'') ORDER BY performance_score DESC NULLS LAST))[1], '') AS urun_ilk_grubu, + COALESCE((ARRAY_AGG(NULLIF(urun_ana_grubu,'') ORDER BY performance_score DESC NULLS LAST))[1], '') AS urun_ana_grubu, + COALESCE((ARRAY_AGG(NULLIF(urun_alt_grubu,'') ORDER BY performance_score DESC NULLS LAST))[1], '') AS urun_alt_grubu, + COALESCE((ARRAY_AGG(NULLIF(market_key,'') ORDER BY performance_score DESC NULLS LAST))[1], '') AS market_key, + COALESCE(SUM(stock_qty),0) AS stock_qty, + COALESCE(SUM(sales_qty_90d),0) AS sales_qty_90d, + COALESCE(SUM(sales_qty_180d),0) AS sales_qty_180d, + COALESCE(SUM(sales_qty_365d),0) AS sales_qty_365d, + COALESCE(SUM(sales_usd_90d),0) AS sales_usd_90d, + COALESCE(SUM(sales_usd_180d),0) AS sales_usd_180d, + COALESCE(SUM(sales_usd_365d),0) AS sales_usd_365d, + CASE WHEN SUM(sales_qty_90d) > 0 THEN SUM(sales_usd_90d) / NULLIF(SUM(sales_qty_90d),0) ELSE 0 END AS avg_price_usd_90d, + CASE WHEN SUM(sales_qty_180d) > 0 THEN SUM(sales_usd_180d) / NULLIF(SUM(sales_qty_180d),0) ELSE 0 END AS avg_price_usd_180d, + CASE WHEN SUM(stock_qty) > 0 THEN SUM(cost_price_usd * stock_qty) / NULLIF(SUM(stock_qty),0) ELSE AVG(cost_price_usd) END AS cost_price_usd, + CASE WHEN SUM(stock_qty) > 0 THEN SUM(base_price_usd * stock_qty) / NULLIF(SUM(stock_qty),0) ELSE AVG(base_price_usd) END AS base_price_usd, + CASE WHEN SUM(stock_qty) > 0 THEN SUM(base_price_try * stock_qty) / NULLIF(SUM(stock_qty),0) ELSE AVG(base_price_try) END AS base_price_try, + COALESCE(SUM(gross_profit_usd_90d),0) AS gross_profit_usd_90d, + COALESCE(SUM(gross_profit_usd_180d),0) AS gross_profit_usd_180d, + CASE WHEN SUM(sales_usd_90d) > 0 THEN SUM(gross_profit_usd_90d) / NULLIF(SUM(sales_usd_90d),0) ELSE 0 END AS gross_margin_90d, + CASE WHEN SUM(sales_usd_180d) > 0 THEN SUM(gross_profit_usd_180d) / NULLIF(SUM(sales_usd_180d),0) ELSE 0 END AS gross_margin_180d, + CASE WHEN SUM(sales_qty_90d) > 0 THEN SUM(unit_profit_cost_90d * sales_qty_90d) / NULLIF(SUM(sales_qty_90d),0) ELSE AVG(unit_profit_cost_90d) END AS unit_profit_cost_90d, + CASE WHEN SUM(sales_qty_180d) > 0 THEN SUM(unit_profit_cost_180d * sales_qty_180d) / NULLIF(SUM(sales_qty_180d),0) ELSE AVG(unit_profit_cost_180d) END AS unit_profit_cost_180d, + CASE WHEN SUM(sales_qty_90d) > 0 THEN SUM(unit_profit_base_90d * sales_qty_90d) / NULLIF(SUM(sales_qty_90d),0) ELSE AVG(unit_profit_base_90d) END AS unit_profit_base_90d, + CASE WHEN SUM(sales_qty_180d) > 0 THEN SUM(unit_profit_base_180d * sales_qty_180d) / NULLIF(SUM(sales_qty_180d),0) ELSE AVG(unit_profit_base_180d) END AS unit_profit_base_180d, + COALESCE(SUM(market_count_90d),0)::integer AS market_count_90d, + COALESCE(SUM(customer_count_90d),0)::integer AS customer_count_90d, + CASE WHEN SUM(sales_qty_90d) > 0 THEN SUM(sales_index_90d * sales_qty_90d) / NULLIF(SUM(sales_qty_90d),0) ELSE AVG(sales_index_90d) END AS sales_index_90d, + CASE WHEN SUM(sales_qty_90d) > 0 THEN SUM(price_index_90d * sales_qty_90d) / NULLIF(SUM(sales_qty_90d),0) ELSE AVG(price_index_90d) END AS price_index_90d, + CASE WHEN SUM(sales_qty_90d) > 0 THEN SUM(margin_index_90d * sales_qty_90d) / NULLIF(SUM(sales_qty_90d),0) ELSE AVG(margin_index_90d) END AS margin_index_90d, + CASE WHEN SUM(sales_qty_90d) > 0 THEN SUM(performance_score * sales_qty_90d) / NULLIF(SUM(sales_qty_90d),0) ELSE AVG(performance_score) END AS performance_score, + MODE() WITHIN GROUP (ORDER BY performance_bucket) AS performance_bucket, + COALESCE(SUM(idle_cost_usd),0) AS idle_cost_usd + FROM Source + %s + GROUP BY COALESCE(NULLIF(%s,''), '-') +) g +ORDER BY group_value +`, len(args)+1, len(args)+1, len(args)+1, len(args)+1, len(args)+1, len(args)+1, len(args)+1, len(args)+1, len(args)+1, groupExpr, whereSQL, groupExpr) + args = append(args, field) + return queryProductPerformanceJSONRows(ctx, pg, query, args...) +} + +func queryProductPerformanceSQLLeafRows(ctx context.Context, pg *sql.DB, mode string, filters []productPerformanceSQLGroupFilter, limit int) ([]map[string]any, error) { + whereSQL, args, err := productPerformanceSQLFilterWhere(filters) + if err != nil { + return nil, err + } + args = append(args, limit) + query := productPerformanceSQLSourceCTE(mode) + fmt.Sprintf(` +SELECT to_jsonb(t) || jsonb_build_object( + 'row_key', 'leaf|' || product_code || '|' || color_code || '|' || yaka_kodu || '|' || market_key, + 'stock_turnover_90d', CASE WHEN stock_qty > 0 THEN sales_qty_90d / NULLIF(stock_qty,0) ELSE 0 END, + 'stock_turnover_180d', CASE WHEN stock_qty > 0 THEN sales_qty_180d / NULLIF(stock_qty,0) ELSE 0 END, + 'stock_turnover_365d', CASE WHEN stock_qty > 0 THEN sales_qty_365d / NULLIF(stock_qty,0) ELSE 0 END +) AS row_json +FROM Source t +%s +ORDER BY performance_score DESC, sales_usd_90d DESC +LIMIT $%d +`, whereSQL, len(args)) + return queryProductPerformanceJSONRows(ctx, pg, query, args...) +} + +func queryProductPerformanceJSONRows(ctx context.Context, pg *sql.DB, query string, args ...any) ([]map[string]any, error) { + rows, err := pg.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + out := []map[string]any{} + for rows.Next() { + var raw []byte + if err := rows.Scan(&raw); err != nil { + return nil, err + } + row := map[string]any{} + if err := json.Unmarshal(raw, &row); err != nil { + return nil, err + } + out = append(out, row) + } + return out, rows.Err() +} + +func productPerformanceSQLSourceCTE(mode string) string { + idleWhere := "" + if mode == "idle" { + idleWhere = ` + AND stock_qty > 0 + AND (performance_bucket = 'STOK_RISKI' OR COALESCE(sales_qty_90d,0) = 0 OR COALESCE(stock_days_90d,0) > 180)` + } + return ` +WITH Source AS ( + SELECT + product_code, + color_code, + yaka_kodu, + item_description, + kategori, + seri, + yas_grubu, + askili_yan, + COALESCE(NULLIF(urun_ilk_grubu,''), yas_grubu) AS urun_ilk_grubu, + COALESCE(NULLIF(urun_ana_grubu,''), seri) AS urun_ana_grubu, + urun_alt_grubu, + market_key, + COALESCE(stock_qty,0) AS stock_qty, + COALESCE(sales_qty_90d,0) AS sales_qty_90d, + COALESCE(sales_qty_180d,0) AS sales_qty_180d, + COALESCE(sales_qty_365d,0) AS sales_qty_365d, + COALESCE(sales_usd_90d,0) AS sales_usd_90d, + COALESCE(sales_usd_180d,0) AS sales_usd_180d, + COALESCE(sales_usd_365d,0) AS sales_usd_365d, + COALESCE(avg_price_usd_90d,0) AS avg_price_usd_90d, + COALESCE(avg_price_usd_180d,0) AS avg_price_usd_180d, + COALESCE(cost_price_usd,0) AS cost_price_usd, + COALESCE(base_price_usd,0) AS base_price_usd, + COALESCE(base_price_try,0) AS base_price_try, + COALESCE(gross_profit_usd_90d,0) AS gross_profit_usd_90d, + COALESCE(gross_profit_usd_180d,0) AS gross_profit_usd_180d, + COALESCE(gross_margin_90d,0) AS gross_margin_90d, + COALESCE(gross_margin_180d,0) AS gross_margin_180d, + COALESCE(unit_profit_cost_90d,0) AS unit_profit_cost_90d, + COALESCE(unit_profit_cost_180d,0) AS unit_profit_cost_180d, + COALESCE(unit_profit_base_90d,0) AS unit_profit_base_90d, + COALESCE(unit_profit_base_180d,0) AS unit_profit_base_180d, + COALESCE(market_count_90d,0) AS market_count_90d, + COALESCE(customer_count_90d,0) AS customer_count_90d, + COALESCE(sales_index_90d,0) AS sales_index_90d, + COALESCE(price_index_90d,0) AS price_index_90d, + COALESCE(margin_index_90d,0) AS margin_index_90d, + COALESCE(performance_score,0) AS performance_score, + COALESCE(performance_bucket,'') AS performance_bucket, + COALESCE(stock_qty,0) * COALESCE(cost_price_usd,0) AS idle_cost_usd + FROM mk_product_performance_kpi_daily + WHERE kpi_date = (SELECT MAX(kpi_date) FROM mk_product_performance_kpi_daily)` + idleWhere + ` +)` +} + +func productPerformanceSQLGroupExpr(field string) (string, bool) { + switch field { + case "kategori", "askili_yan", "urun_ilk_grubu", "urun_ana_grubu", "urun_alt_grubu", "product_code", "color_code", "yaka_kodu": + return field, true + case "market_key": + return "btrim(regexp_replace(COALESCE(market_key,''), '^.*\\|', ''))", true + default: + return "", false + } +} + +func productPerformanceSQLFilterWhere(filters []productPerformanceSQLGroupFilter) (string, []any, error) { + if len(filters) == 0 { + return "", nil, nil + } + parts := make([]string, 0, len(filters)) + args := make([]any, 0, len(filters)) + for _, filter := range filters { + expr, ok := productPerformanceSQLGroupExpr(filter.Field) + if !ok { + return "", nil, fmt.Errorf("unsupported product performance filter field: %s", filter.Field) + } + args = append(args, normalizeProductPerformanceGroupValue(filter.Value)) + parts = append(parts, fmt.Sprintf("COALESCE(NULLIF(%s,''), '-') = $%d", expr, len(args))) + } + return "WHERE " + strings.Join(parts, " AND "), args, nil +} + func productPerformanceGroupedSourceRows(ctx context.Context, pg *sql.DB, mode string, limit int) ([]map[string]any, error) { switch mode { case "products": diff --git a/ui/src/pages/ProductPerformanceProfitability.vue b/ui/src/pages/ProductPerformanceProfitability.vue index 91b2f02..aafe8d6 100644 --- a/ui/src/pages/ProductPerformanceProfitability.vue +++ b/ui/src/pages/ProductPerformanceProfitability.vue @@ -1,5 +1,22 @@