ui: update ProductPerformanceProfitability page to enhance table views and tabs management

This commit is contained in:
M_Kececi
2026-07-03 00:30:54 +03:00
parent c536b85a4c
commit be6feb8aa8
3 changed files with 710 additions and 71 deletions
+300
View File
@@ -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":