ui: update ProductPerformanceProfitability page to enhance table views and tabs management
This commit is contained in:
@@ -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":
|
||||
|
||||
Reference in New Issue
Block a user