diff --git a/svc/queries/product_performance.go b/svc/queries/product_performance.go index 7da050a..918d5b0 100644 --- a/svc/queries/product_performance.go +++ b/svc/queries/product_performance.go @@ -10,6 +10,7 @@ import ( "log" "os" "sort" + "strconv" "strings" "time" @@ -581,6 +582,35 @@ CREATE TABLE IF NOT EXISTS mk_product_performance_report_snapshot_meta ( row_count INTEGER NOT NULL DEFAULT 0, refreshed_at TIMESTAMPTZ NOT NULL DEFAULT now(), duration_ms BIGINT NOT NULL DEFAULT 0 +)`, + ` +CREATE TABLE IF NOT EXISTS mk_product_performance_grouped_snapshot ( + report_key TEXT NOT NULL, + row_order INTEGER NOT NULL, + mode TEXT NOT NULL, + group_levels_key TEXT NOT NULL, + main_group TEXT NOT NULL DEFAULT '', + group_level INTEGER NOT NULL DEFAULT 0, + group_key TEXT NOT NULL DEFAULT '', + parent_key TEXT NOT NULL DEFAULT '', + group_field TEXT NOT NULL DEFAULT '', + group_value TEXT NOT NULL DEFAULT '', + payload JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT pk_mk_product_performance_grouped_snapshot PRIMARY KEY (report_key, row_order) +)`, + `CREATE INDEX IF NOT EXISTS ix_mk_product_perf_grouped_snapshot_key ON mk_product_performance_grouped_snapshot (report_key, row_order)`, + `CREATE INDEX IF NOT EXISTS ix_mk_product_perf_grouped_snapshot_level ON mk_product_performance_grouped_snapshot (report_key, group_level, row_order)`, + ` +CREATE TABLE IF NOT EXISTS mk_product_performance_grouped_snapshot_meta ( + report_key TEXT PRIMARY KEY, + mode TEXT NOT NULL, + group_levels_key TEXT NOT NULL, + main_group TEXT NOT NULL DEFAULT '', + row_count INTEGER NOT NULL DEFAULT 0, + refreshed_at TIMESTAMPTZ NOT NULL DEFAULT now(), + duration_ms BIGINT NOT NULL DEFAULT 0 )`, } for _, stmt := range stmts { @@ -669,6 +699,62 @@ SELECT EXISTS ( return out, exists, nil } +func loadProductPerformanceSnapshotMapRows(ctx context.Context, pg *sql.DB, reportKey string, limit int) ([]map[string]any, bool, error) { + if !productPerformanceUseSnapshot(ctx) || pg == nil || strings.TrimSpace(reportKey) == "" { + return nil, false, nil + } + if limit <= 0 || limit > 50000 { + limit = 50000 + } + rows, err := pg.QueryContext(ctx, ` +SELECT payload +FROM mk_product_performance_report_snapshot +WHERE report_key = $1 +ORDER BY row_order +LIMIT $2 +`, reportKey, limit) + if err != nil { + return nil, false, err + } + defer rows.Close() + + out := make([]map[string]any, 0, limit) + for rows.Next() { + var raw []byte + if err := rows.Scan(&raw); err != nil { + return nil, false, err + } + var item map[string]any + if err := json.Unmarshal(raw, &item); err != nil { + return nil, false, err + } + if item == nil { + item = map[string]any{} + } + out = append(out, item) + } + if err := rows.Err(); err != nil { + return nil, false, err + } + if len(out) > 0 { + return out, true, nil + } + var exists bool + if err := pg.QueryRowContext(ctx, ` +SELECT EXISTS ( + SELECT 1 + FROM mk_product_performance_report_snapshot_meta + WHERE report_key = $1 +) +`, reportKey).Scan(&exists); err != nil { + return nil, false, err + } + if !exists && !productPerformanceLiveFallbackEnabled() { + return out, true, nil + } + return out, exists, nil +} + func loadProductPerformanceSnapshotItem[T any](ctx context.Context, pg *sql.DB, reportKey string) (T, bool, error) { var zero T rows, ok, err := loadProductPerformanceSnapshotRows[T](ctx, pg, reportKey, 1) @@ -721,10 +807,78 @@ ON CONFLICT (report_key) DO UPDATE SET return tx.Commit() } +func saveProductPerformanceGroupedSnapshotRows(ctx context.Context, pg *sql.DB, reportKey string, def productPerformanceGroupedSnapshotDefinition, rows []map[string]any, started time.Time) error { + if pg == nil || strings.TrimSpace(reportKey) == "" { + return nil + } + tx, err := pg.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + + if _, err := tx.ExecContext(ctx, `DELETE FROM mk_product_performance_grouped_snapshot WHERE report_key = $1`, reportKey); err != nil { + return err + } + stmt, err := tx.PrepareContext(ctx, ` +INSERT INTO mk_product_performance_grouped_snapshot ( + report_key, row_order, mode, group_levels_key, main_group, + group_level, group_key, parent_key, group_field, group_value, payload, updated_at +) +VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,now()) +`) + if err != nil { + return err + } + defer stmt.Close() + + levelsKey := productPerformanceGroupedLevelsKey(def.Levels) + for i, row := range rows { + raw, err := json.Marshal(row) + if err != nil { + return err + } + key := stringFromMap(row, "key") + if _, err := stmt.ExecContext( + ctx, + reportKey, + i, + strings.TrimSpace(def.Mode), + levelsKey, + strings.TrimSpace(def.MainGroup), + intFromMap(row, "level"), + key, + productPerformanceParentGroupKey(key), + stringFromMap(row, "group_field"), + stringFromMap(row, "group_value"), + raw, + ); err != nil { + return err + } + } + if _, err := tx.ExecContext(ctx, ` +INSERT INTO mk_product_performance_grouped_snapshot_meta ( + report_key, mode, group_levels_key, main_group, row_count, refreshed_at, duration_ms +) +VALUES ($1,$2,$3,$4,$5,now(),$6) +ON CONFLICT (report_key) DO UPDATE SET + mode = EXCLUDED.mode, + group_levels_key = EXCLUDED.group_levels_key, + main_group = EXCLUDED.main_group, + row_count = EXCLUDED.row_count, + refreshed_at = EXCLUDED.refreshed_at, + duration_ms = EXCLUDED.duration_ms +`, reportKey, strings.TrimSpace(def.Mode), levelsKey, strings.TrimSpace(def.MainGroup), len(rows), time.Since(started).Milliseconds()); err != nil { + return err + } + return tx.Commit() +} + func RebuildProductPerformanceReportSnapshots(ctx context.Context, pg *sql.DB) (int, error) { if err := EnsureProductPerformanceTables(pg); err != nil { return 0, err } + snapshotReadCtx := ctx ctx = productPerformanceSnapshotBypass(ctx) total := 0 @@ -846,9 +1000,97 @@ func RebuildProductPerformanceReportSnapshots(ctx context.Context, pg *sql.DB) ( return total, err } } + groupedRows, err := RebuildProductPerformanceGroupedSnapshots(snapshotReadCtx, pg) + if err != nil { + return total, err + } + total += groupedRows return total, nil } +type productPerformanceGroupedSnapshotDefinition struct { + Mode string + Levels []string + MainGroup string +} + +func RebuildProductPerformanceGroupedSnapshots(ctx context.Context, pg *sql.DB) (int, error) { + if err := EnsureProductPerformanceTables(pg); err != nil { + return 0, err + } + defs, err := productPerformanceGroupedSnapshotDefinitions(ctx, pg) + if err != nil { + return 0, err + } + total := 0 + for _, def := range defs { + started := time.Now() + levels := sanitizeProductPerformanceGroupLevels(def.Levels) + if len(levels) == 0 { + levels = defaultProductPerformanceGroupLevels(def.Mode) + } + sourceRows, err := productPerformanceGroupedRawSnapshotSourceRows(ctx, pg, def.Mode, 50000) + if err != nil { + return total, err + } + req := ProductPerformanceGroupedRequest{ + Mode: def.Mode, + MainGroup: def.MainGroup, + } + sourceRows = filterProductPerformanceGroupedRows(sourceRows, productPerformanceGroupedEffectiveFilters(req)) + rows := make([]map[string]any, 0, len(sourceRows)) + appendProductPerformanceGroupedRows(&rows, sourceRows, levels, 0, 0, []string{"tab:" + def.Mode}, nil, len(levels)-1) + reportKey := productPerformanceGroupedSnapshotReportKey(def.Mode, levels, def.MainGroup) + if err := saveProductPerformanceGroupedSnapshotRows(ctx, pg, reportKey, productPerformanceGroupedSnapshotDefinition{ + Mode: def.Mode, + Levels: levels, + MainGroup: def.MainGroup, + }, rows, started); err != nil { + return total, err + } + total += len(rows) + } + return total, nil +} + +func productPerformanceGroupedSnapshotDefinitions(ctx context.Context, pg *sql.DB) ([]productPerformanceGroupedSnapshotDefinition, error) { + defs := []productPerformanceGroupedSnapshotDefinition{ + {Mode: "products", Levels: []string{"urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "color_yaka", "market_key"}}, + {Mode: "products", Levels: []string{"urun_ana_grubu"}}, + {Mode: "idle", Levels: []string{"urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "color_yaka"}}, + {Mode: "sales_color_yaka_market_customer", Levels: []string{"color_yaka", "urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "country", "market_key", "customer_segment", "customer_code", "customer_name"}}, + {Mode: "sales_product_country_segment_market_customer", Levels: []string{"urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "color_yaka", "country", "customer_segment", "market_key", "customer_code", "customer_name"}}, + {Mode: "sales_country_segment_market_customer_product", Levels: []string{"country", "customer_segment", "market_key", "customer_code", "customer_name", "urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "color_yaka"}}, + {Mode: "order_product_customers", Levels: []string{"urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "color_yaka", "market_key", "customer_code", "customer_name"}}, + {Mode: "order_market_details", Levels: []string{"market_key", "customer_code", "customer_name", "urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "color_yaka"}}, + } + productRows, ok, err := loadProductPerformanceSnapshotMapRows(ctx, pg, productPerformanceSnapshotKey("products"), 50000) + if err != nil { + return nil, err + } + if ok { + mainGroups := make([]string, 0) + seen := map[string]bool{} + for _, row := range productRows { + mainGroup := productPerformanceGroupedFilterValue(row, "urun_ana_grubu") + if mainGroup == "" || seen[mainGroup] { + continue + } + seen[mainGroup] = true + mainGroups = append(mainGroups, mainGroup) + } + sort.Strings(mainGroups) + for _, mainGroup := range mainGroups { + defs = append(defs, productPerformanceGroupedSnapshotDefinition{ + Mode: "product_detail", + Levels: []string{"urun_alt_grubu", "product_code", "color_yaka", "market_key"}, + MainGroup: mainGroup, + }) + } + } + return defs, nil +} + func RefreshProductPerformance(ctx context.Context, pg *sql.DB, req ProductPerformanceRefreshRequest) (ProductPerformanceRefreshResult, error) { started := time.Now() stage := productPerformanceRefreshStage(req.Stage) @@ -3472,9 +3714,14 @@ type ProductPerformanceGroupedRequest struct { Limit int MainGroup string Filters map[string][]string + SortBy string + Descending bool } func ListProductPerformanceGrouped(ctx context.Context, pg *sql.DB, req ProductPerformanceGroupedRequest) ([]map[string]any, error) { + if err := EnsureProductPerformanceTables(pg); err != nil { + return nil, err + } if req.Limit <= 0 || req.Limit > 50000 { req.Limit = 50000 } @@ -3486,6 +3733,13 @@ func ListProductPerformanceGrouped(ctx context.Context, pg *sql.DB, req ProductP req.ExpandThroughLevel = len(levels) - 2 } + if out, ok, err := loadProductPerformancePreparedGroupedRows(ctx, pg, req, levels); ok || err != nil { + return out, err + } + if !productPerformanceLiveFallbackEnabled() { + return []map[string]any{}, nil + } + sourceRows, err := productPerformanceGroupedSourceRows(ctx, pg, req.Mode, req.Limit) if err != nil { return nil, err @@ -3502,6 +3756,215 @@ func ListProductPerformanceGrouped(ctx context.Context, pg *sql.DB, req ProductP return out, nil } +func loadProductPerformancePreparedGroupedRows(ctx context.Context, pg *sql.DB, req ProductPerformanceGroupedRequest, levels []string) ([]map[string]any, bool, error) { + reportKey := productPerformanceGroupedSnapshotReportKey(req.Mode, levels, req.MainGroup) + if strings.TrimSpace(reportKey) == "" { + return nil, false, nil + } + effectiveFilters := productPerformanceGroupedEffectiveFilters(req) + hasFilters := len(effectiveFilters) > 0 + hasManualExpansion := len(req.ExpandedKeys) > 0 + query := ` +SELECT payload +FROM mk_product_performance_grouped_snapshot +WHERE report_key = $1 +` + args := []any{reportKey} + if !hasFilters && !hasManualExpansion { + maxVisibleLevel := req.ExpandThroughLevel + 1 + if maxVisibleLevel < 0 { + maxVisibleLevel = 0 + } + args = append(args, maxVisibleLevel) + query += fmt.Sprintf(" AND group_level <= $%d\n", len(args)) + } + query += "ORDER BY row_order" + if !hasFilters && !hasManualExpansion { + args = append(args, req.Limit) + query += fmt.Sprintf("\nLIMIT $%d", len(args)) + } + + rows, err := pg.QueryContext(ctx, query, args...) + if err != nil { + return nil, false, err + } + defer rows.Close() + + out := make([]map[string]any, 0, minInt(req.Limit, 50000)) + for rows.Next() { + var raw []byte + if err := rows.Scan(&raw); err != nil { + return nil, false, err + } + var item map[string]any + if err := json.Unmarshal(raw, &item); err != nil { + return nil, false, err + } + if item == nil { + item = map[string]any{} + } + out = append(out, item) + } + if err := rows.Err(); err != nil { + return nil, false, err + } + if len(out) == 0 { + var exists bool + if err := pg.QueryRowContext(ctx, ` +SELECT EXISTS ( + SELECT 1 + FROM mk_product_performance_grouped_snapshot_meta + WHERE report_key = $1 +) +`, reportKey).Scan(&exists); err != nil { + return nil, false, err + } + if !exists { + return nil, false, nil + } + } + out = filterProductPerformancePreparedGroupedRows(out, effectiveFilters) + out = filterProductPerformanceGroupedRowsForExpansion(out, req.ExpandedKeys, req.ExpandThroughLevel, req.Limit) + out = sortProductPerformancePreparedGroupedRows(out, req.SortBy, req.Descending) + return out, true, nil +} + +type productPerformanceGroupedSortNode struct { + Row map[string]any + Children []*productPerformanceGroupedSortNode +} + +func sortProductPerformancePreparedGroupedRows(rows []map[string]any, sortBy string, descending bool) []map[string]any { + sortBy = strings.TrimSpace(sortBy) + if len(rows) == 0 || sortBy == "" { + return rows + } + roots := make([]*productPerformanceGroupedSortNode, 0) + stack := make([]*productPerformanceGroupedSortNode, 0, 16) + for _, row := range rows { + node := &productPerformanceGroupedSortNode{Row: row} + level := intFromMap(row, "level") + if level < 0 { + level = 0 + } + if level < len(stack) { + stack = stack[:level] + } + if level > 0 && level-1 < len(stack) { + stack[level-1].Children = append(stack[level-1].Children, node) + } else { + roots = append(roots, node) + } + if level >= len(stack) { + stack = append(stack, node) + } else { + stack[level] = node + } + } + + out := make([]map[string]any, 0, len(rows)) + var appendNodes func(nodes []*productPerformanceGroupedSortNode) + appendNodes = func(nodes []*productPerformanceGroupedSortNode) { + sort.SliceStable(nodes, func(i, j int) bool { + cmp := compareProductPerformanceGroupedSortRows(nodes[i].Row, nodes[j].Row, sortBy) + if cmp == 0 { + cmp = strings.Compare(strings.ToLower(stringFromMap(nodes[i].Row, "label")), strings.ToLower(stringFromMap(nodes[j].Row, "label"))) + } + if descending { + return cmp > 0 + } + return cmp < 0 + }) + for _, node := range nodes { + out = append(out, node.Row) + if len(node.Children) > 0 { + appendNodes(node.Children) + } + } + } + appendNodes(roots) + return out +} + +func compareProductPerformanceGroupedSortRows(left, right map[string]any, sortBy string) int { + leftValue := productPerformanceGroupedSortValue(left, sortBy) + rightValue := productPerformanceGroupedSortValue(right, sortBy) + leftNum, leftOK := numericProductPerformanceGroupedSortValue(leftValue) + rightNum, rightOK := numericProductPerformanceGroupedSortValue(rightValue) + if leftOK && rightOK { + switch { + case leftNum < rightNum: + return -1 + case leftNum > rightNum: + return 1 + default: + return 0 + } + } + leftText := strings.ToLower(strings.TrimSpace(fmt.Sprint(leftValue))) + rightText := strings.ToLower(strings.TrimSpace(fmt.Sprint(rightValue))) + return strings.Compare(leftText, rightText) +} + +func productPerformanceGroupedSortValue(row map[string]any, sortBy string) any { + sortBy = strings.TrimSpace(sortBy) + if sortBy == "" { + return "" + } + if value, ok := row[sortBy]; ok { + return value + } + switch sortBy { + case "performance_score_total", "performance_score_90d": + return row["performance_score"] + case "gross_margin_base_total": + return row["gross_margin_total"] + case "gross_margin_cost_total": + return row["gross_margin_total"] + case "color_yaka": + return mapGroupValue(row, "color_yaka") + case "market_key": + return mapGroupValue(row, "market_key") + default: + if sortBy == stringFromMap(row, "group_field") { + return stringFromMap(row, "group_value") + } + return stringFromMap(row, "label") + } +} + +func numericProductPerformanceGroupedSortValue(value any) (float64, bool) { + switch v := value.(type) { + case nil: + return 0, false + case float64: + return v, true + case float32: + return float64(v), true + case int: + return float64(v), true + case int64: + return float64(v), true + case int32: + return float64(v), true + case json.Number: + f, err := v.Float64() + return f, err == nil + case string: + text := strings.TrimSpace(v) + if text == "" { + return 0, false + } + parsed, err := strconv.ParseFloat(text, 64) + if err != nil { + return 0, false + } + return parsed, true + default: + return 0, false + } +} + type productPerformanceSQLGroupFilter struct { Field string Value string @@ -4018,6 +4481,139 @@ func filterProductPerformanceGroupedRows(rows []map[string]any, filters map[stri return out } +func filterProductPerformancePreparedGroupedRows(rows []map[string]any, filters map[string][]string) []map[string]any { + if len(filters) == 0 || len(rows) == 0 { + return rows + } + cleanFilters := map[string]map[string]bool{} + for field, values := range filters { + cleanValues := cleanProductPerformanceFilterValues(values) + if len(cleanValues) == 0 { + continue + } + set := map[string]bool{} + for _, value := range cleanValues { + set[value] = true + } + cleanFilters[field] = set + } + if len(cleanFilters) == 0 { + return rows + } + + matchedKeys := map[string]bool{} + allowedKeys := map[string]bool{} + for _, row := range rows { + matches := true + for field, allowed := range cleanFilters { + if !allowed[productPerformanceGroupedFilterValue(row, field)] { + matches = false + break + } + } + if !matches { + continue + } + key := stringFromMap(row, "key") + if key == "" { + continue + } + matchedKeys[key] = true + for _, ancestor := range productPerformanceGroupKeyAncestors(key, true) { + allowedKeys[ancestor] = true + } + } + if len(allowedKeys) == 0 { + return []map[string]any{} + } + out := make([]map[string]any, 0, len(rows)) + for _, row := range rows { + key := stringFromMap(row, "key") + if allowedKeys[key] || productPerformanceGroupRowHasMatchedAncestor(key, matchedKeys) { + out = append(out, row) + } + } + return out +} + +func productPerformanceGroupRowHasMatchedAncestor(key string, matchedKeys map[string]bool) bool { + for _, ancestor := range productPerformanceGroupKeyAncestors(key, true) { + if matchedKeys[ancestor] { + return true + } + } + return false +} + +func filterProductPerformanceGroupedRowsForExpansion(rows []map[string]any, expandedKeys map[string]bool, expandThroughLevel int, limit int) []map[string]any { + if len(rows) == 0 { + return rows + } + if limit <= 0 || limit > 50000 { + limit = 50000 + } + out := make([]map[string]any, 0, minInt(limit, len(rows))) + for _, row := range rows { + if !productPerformanceGroupedRowVisible(row, expandedKeys, expandThroughLevel) { + continue + } + out = append(out, row) + if len(out) >= limit { + break + } + } + return out +} + +func productPerformanceGroupedRowVisible(row map[string]any, expandedKeys map[string]bool, expandThroughLevel int) bool { + level := intFromMap(row, "level") + if level <= 0 { + return true + } + key := stringFromMap(row, "key") + if key == "" { + return true + } + ancestors := productPerformanceGroupKeyAncestors(key, false) + for ancestorLevel, ancestor := range ancestors { + if ancestorLevel <= expandThroughLevel { + continue + } + if expandedKeys != nil && expandedKeys[ancestor] { + continue + } + return false + } + return true +} + +func productPerformanceGroupKeyAncestors(key string, includeSelf bool) []string { + parts := strings.Split(strings.TrimSpace(key), "|") + if len(parts) <= 1 { + return nil + } + last := len(parts) - 1 + if !includeSelf { + last-- + } + if last < 1 { + return nil + } + out := make([]string, 0, last) + for i := 1; i <= last; i++ { + out = append(out, strings.Join(parts[:i+1], "|")) + } + return out +} + +func productPerformanceParentGroupKey(key string) string { + parts := strings.Split(strings.TrimSpace(key), "|") + if len(parts) <= 2 { + return "" + } + return strings.Join(parts[:len(parts)-1], "|") +} + func productPerformanceGroupedFilterValue(row map[string]any, field string) string { switch field { case "urun_ana_grubu": @@ -4037,6 +4633,12 @@ func productPerformanceGroupedFilterValue(row map[string]any, field string) stri } func productPerformanceGroupedSourceRows(ctx context.Context, pg *sql.DB, mode string, limit int) ([]map[string]any, error) { + if rows, ok, err := productPerformanceGroupedSnapshotRows(ctx, pg, mode, limit); err != nil { + return nil, err + } else if ok { + return rows, nil + } + switch mode { case "products": rows, _, err := ListProductPerformance(ctx, pg, ProductPerformanceFilters{Limit: limit, Page: 1, SortBy: "performance_score", Descending: true}) @@ -4062,6 +4664,74 @@ func productPerformanceGroupedSourceRows(ctx context.Context, pg *sql.DB, mode s } } +func productPerformanceGroupedRawSnapshotSourceRows(ctx context.Context, pg *sql.DB, mode string, limit int) ([]map[string]any, error) { + reportKey, ok := productPerformanceGroupedSnapshotKey(mode) + if !ok { + return nil, nil + } + rows, exists, err := loadProductPerformanceSnapshotMapRows(ctx, pg, reportKey, limit) + if err != nil { + return nil, err + } + if !exists { + return nil, nil + } + if strings.TrimSpace(mode) == "idle" { + return productPerformanceIdleSourceRows(rows), nil + } + return rows, nil +} + +func productPerformanceGroupedSnapshotRows(ctx context.Context, pg *sql.DB, mode string, limit int) ([]map[string]any, bool, error) { + reportKey, ok := productPerformanceGroupedSnapshotKey(mode) + if !ok { + return nil, false, nil + } + rows, exists, err := loadProductPerformanceSnapshotMapRows(ctx, pg, reportKey, limit) + if err != nil || !exists { + return rows, exists, err + } + if strings.TrimSpace(mode) == "idle" { + return productPerformanceIdleSourceRows(rows), true, nil + } + return rows, true, nil +} + +func productPerformanceGroupedSnapshotReportKey(mode string, levels []string, mainGroup string) string { + levels = sanitizeProductPerformanceGroupLevels(levels) + if len(levels) == 0 { + levels = defaultProductPerformanceGroupLevels(mode) + } + parts := []string{"grouped", strings.TrimSpace(mode), productPerformanceGroupedLevelsKey(levels)} + if strings.TrimSpace(mainGroup) != "" { + parts = append(parts, strings.TrimSpace(mainGroup)) + } + return productPerformanceSnapshotKey(parts...) +} + +func productPerformanceGroupedLevelsKey(levels []string) string { + levels = sanitizeProductPerformanceGroupLevels(levels) + if len(levels) == 0 { + return "" + } + return strings.Join(levels, ">") +} + +func productPerformanceGroupedSnapshotKey(mode string) (string, bool) { + switch strings.TrimSpace(mode) { + case "products", "product_detail", "idle", "": + return productPerformanceSnapshotKey("products"), true + case "order_product_customers": + return productPerformanceSnapshotKey("order-product-customers"), true + case "order_market_details": + return productPerformanceSnapshotKey("order-market-details"), true + case "sales_color_yaka_market_customer", "sales_product_country_segment_market_customer", "sales_market_customer_product", "sales_country_segment_market_customer_product": + return productPerformanceSnapshotKey("sales-breakdown", productPerformanceSalesBreakdownMode(mode)), true + default: + return "", false + } +} + func productPerformanceSalesBreakdownMode(mode string) string { switch strings.TrimSpace(mode) { case "sales_color_yaka_market_customer": @@ -4293,6 +4963,16 @@ func deriveProductPerformanceGroupMetrics(out map[string]any, groupField string) if qty > 0 { out["avg_price_usd_"+suffix] = sales / qty } + basePrice, hasBasePrice := productPerformanceUnitCostForSuffix(out, "base_price_usd", suffix) + costPrice, hasCostPrice := productPerformanceUnitCostForSuffix(out, "cost_price_usd", suffix) + if qty > 0 && hasBasePrice { + out["unit_profit_base_"+suffix] = (sales / qty) - basePrice + out["gross_profit_base_usd_"+suffix] = sales - (qty * basePrice) + } + if qty > 0 && hasCostPrice { + out["unit_profit_cost_"+suffix] = (sales / qty) - costPrice + out["gross_profit_cost_usd_"+suffix] = sales - (qty * costPrice) + } if sales > 0 { if _, ok := out["gross_profit_base_usd_"+suffix]; ok { out["gross_margin_base_"+suffix] = floatFromMap(out, "gross_profit_base_usd_"+suffix) / sales @@ -4321,7 +5001,28 @@ func deriveProductPerformanceGroupMetrics(out map[string]any, groupField string) out["customer_score_180d"] = productPerformanceCustomerSalesPeriodScore(rowPeriodMetricMap(out, "180d")) out["customer_score_365d"] = productPerformanceCustomerSalesPeriodScore(rowPeriodMetricMap(out, "365d")) out["customer_score_total"] = productPerformanceCustomerSalesPeriodScore(rowPeriodMetricMap(out, "total")) + out["performance_score_90d"] = productPerformanceSalesPeriodScore(out, "90d") + out["performance_score_180d"] = productPerformanceSalesPeriodScore(out, "180d") + out["performance_score_365d"] = productPerformanceSalesPeriodScore(out, "365d") + out["performance_score_total"] = productPerformanceSalesPeriodScore(out, "total") out["performance_score"] = productPerformanceGroupScore(out, groupField) + if floatFromMap(out, "order_qty") > 0 || floatFromMap(out, "order_usd") > 0 { + score := productPerformanceOrderGroupScore(out) + out["performance_score_90d"] = score + out["performance_score_180d"] = score + out["performance_score_365d"] = score + out["performance_score_total"] = score + } +} + +func productPerformanceUnitCostForSuffix(row map[string]any, baseField, suffix string) (float64, bool) { + if value, ok := row[baseField+"_"+suffix]; ok { + return floatFromAny(value), true + } + if value, ok := row[baseField]; ok { + return floatFromAny(value), true + } + return 0, false } func productPerformanceGroupScore(row map[string]any, groupField string) float64 { @@ -4344,11 +5045,18 @@ func isProductPerformanceCustomerGroup(groupField string) bool { } func productPerformanceSalesGroupScore(row map[string]any) float64 { - salesIndex := floatFromMap(row, "sales_index_90d") - margin := floatFromMap(row, "gross_margin_cost_90d") - invoiceCount := floatFromMap(row, "invoice_count_90d") - salesQty := floatFromMap(row, "sales_qty_90d") - stockTurnover := floatFromMap(row, "stock_turnover_90d") + return productPerformanceSalesPeriodScore(row, "90d") +} + +func productPerformanceSalesPeriodScore(row map[string]any, suffix string) float64 { + salesIndex := floatFromMap(row, "sales_index_"+suffix) + margin := floatFromMap(row, "gross_margin_cost_"+suffix) + if margin == 0 { + margin = floatFromMap(row, "gross_margin_"+suffix) + } + invoiceCount := floatFromMap(row, "invoice_count_"+suffix) + salesQty := floatFromMap(row, "sales_qty_"+suffix) + stockTurnover := floatFromMap(row, "stock_turnover_"+suffix) if stockTurnover == 0 { if stockQty := floatFromMap(row, "stock_qty"); stockQty > 0 { stockTurnover = salesQty / stockQty @@ -4462,23 +5170,23 @@ func sanitizeProductPerformanceGroupLevels(levels []string) []string { func defaultProductPerformanceGroupLevels(mode string) []string { switch mode { case "sales_color_yaka_market_customer": - return []string{"color_yaka", "urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "item_description", "country", "market_key", "customer_segment", "customer_code", "customer_name"} + return []string{"color_yaka", "urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "country", "market_key", "customer_segment", "customer_code", "customer_name"} case "product_detail": - return []string{"urun_ana_grubu", "urun_alt_grubu", "product_code", "item_description", "color_yaka", "market_key"} + return []string{"urun_alt_grubu", "product_code", "color_yaka", "market_key"} case "idle": - return []string{"urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "item_description", "color_yaka"} + return []string{"urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "color_yaka"} case "sales_product_country_segment_market_customer": - return []string{"market_key", "customer_code", "customer_name", "urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "item_description", "color_yaka"} + return []string{"urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "color_yaka", "country", "customer_segment", "market_key", "customer_code", "customer_name"} case "sales_market_customer_product": - return []string{"market_key", "customer_code", "customer_name", "urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "item_description", "color_yaka"} + return []string{"market_key", "customer_code", "customer_name", "urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "color_yaka"} case "sales_country_segment_market_customer_product": - return []string{"market_key", "customer_code", "customer_name", "urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "item_description", "color_yaka"} + return []string{"country", "customer_segment", "market_key", "customer_code", "customer_name", "urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "color_yaka"} case "order_product_customers": - return []string{"market_key", "customer_code", "customer_name", "urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "item_description", "color_yaka"} + return []string{"urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "color_yaka", "market_key", "customer_code", "customer_name"} case "order_market_details": - return []string{"market_key", "customer_code", "customer_name", "urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "item_description", "color_yaka"} + return []string{"market_key", "customer_code", "customer_name", "urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "color_yaka"} default: - return []string{"urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "item_description", "color_yaka", "market_key"} + return []string{"urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "color_yaka", "market_key"} } } @@ -4671,6 +5379,10 @@ func floatFromMap(row map[string]any, field string) float64 { return floatFromAny(row[field]) } +func intFromMap(row map[string]any, field string) int { + return int(floatFromAny(row[field])) +} + func floatFromAny(value any) float64 { switch v := value.(type) { case float64: @@ -4689,6 +5401,13 @@ func floatFromAny(value any) float64 { } } +func minInt(a, b int) int { + if a < b { + return a + } + return b +} + func ListProductPerformanceSalesDetails(ctx context.Context, pg *sql.DB, productCode, colorCode, yakaKodu string, limit int) ([]models.ProductPerformanceSalesDetailRow, error) { if limit <= 0 || limit > 500 { limit = 100 diff --git a/svc/routes/product_performance.go b/svc/routes/product_performance.go index 72374ed..1c4c2c6 100644 --- a/svc/routes/product_performance.go +++ b/svc/routes/product_performance.go @@ -6,6 +6,7 @@ import ( "context" "database/sql" "encoding/json" + "log" "net/http" "strconv" "strings" @@ -270,6 +271,8 @@ func GetProductPerformanceGroupedHandler(pg *sql.DB) http.HandlerFunc { mode := strings.TrimSpace(r.URL.Query().Get("mode")) mainGroup := strings.TrimSpace(r.URL.Query().Get("urun_ana_grubu")) + sortBy := strings.TrimSpace(r.URL.Query().Get("sort_by")) + descending := boolQuery(r, "descending", true) groupLevels := splitCSVQuery(r.URL.Query().Get("group_levels")) limit := intQuery(r, "limit", 50000) expanded := map[string]bool{} @@ -296,6 +299,8 @@ func GetProductPerformanceGroupedHandler(pg *sql.DB) http.HandlerFunc { Limit int `json:"limit"` MainGroup string `json:"urun_ana_grubu"` Filters map[string][]string `json:"filters"` + SortBy string `json:"sort_by"` + Descending *bool `json:"descending"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { http.Error(w, "gecersiz grup istegi: "+err.Error(), http.StatusBadRequest) @@ -322,6 +327,12 @@ func GetProductPerformanceGroupedHandler(pg *sql.DB) http.HandlerFunc { if len(body.Filters) > 0 { filters = body.Filters } + if strings.TrimSpace(body.SortBy) != "" { + sortBy = strings.TrimSpace(body.SortBy) + } + if body.Descending != nil { + descending = *body.Descending + } } else { for _, key := range strings.Split(r.URL.Query().Get("expanded_keys"), ",") { addExpandedKey(key) @@ -336,8 +347,11 @@ func GetProductPerformanceGroupedHandler(pg *sql.DB) http.HandlerFunc { Limit: limit, MainGroup: mainGroup, Filters: filters, + SortBy: sortBy, + Descending: descending, }) if err != nil { + log.Printf("[ProductPerformance][grouped] failed trace_id=%s mode=%s levels=%v expand_through_level=%d filters=%d: %v", traceID, mode, groupLevels, expandThroughLevel, len(filters), err) http.Error(w, "urun performans grup verisi alinamadi: "+err.Error(), http.StatusInternalServerError) return } diff --git a/ui/src/pages/ProductPerformanceProfitability.vue b/ui/src/pages/ProductPerformanceProfitability.vue index 37ce2a9..c9ac1a4 100644 --- a/ui/src/pages/ProductPerformanceProfitability.vue +++ b/ui/src/pages/ProductPerformanceProfitability.vue @@ -1706,6 +1706,12 @@ const activeSalesBreakdownPagination = computed({ } }) +const activeBackendGroupedSortSignature = computed(() => { + if (!backendGroupedSupportedTab(activeTab.value)) return '' + const state = tablePagination[activeTab.value] || {} + return `${activeTab.value}|${state.sortBy || ''}|${state.descending !== false ? 1 : 0}` +}) + function normalizeTablePagination (value) { return { page: Number(value?.page || 1), @@ -3014,6 +3020,7 @@ function groupedBucketSortValue (sourceRows, sortBy) { function sortProductGroupedTableRows (tableRows, sortBy, descending) { const source = Array.isArray(tableRows) ? tableRows : [] + if (backendGroupedSupportedTab(activeTab.value)) return source if (!sortBy || !source.some(row => row?.__group)) { return sortFlatTableRows(source, sortBy, descending) } @@ -4619,14 +4626,17 @@ async function loadBackendGroupedRows (options = {}) { backendGroupedLoading.value = true const filtersKey = JSON.stringify(filterState.filters) const expandThroughLevel = selectedExpandThroughLevel.value - const requestKey = `${tabKey}|${expandThroughLevel}|${selectedDetailMainGroup.value || ''}|${filtersKey}` + const sortState = tableSortState(tabKey) + const requestKey = `${tabKey}|${expandThroughLevel}|${selectedDetailMainGroup.value || ''}|${filtersKey}|${sortState.sortBy}|${sortState.descending ? 1 : 0}` const params = { mode: tabKey, groupLevels: activeGroupLevels.value.map(level => level.key), expandedKeys: activeExpandedGroupKeys(), expandThroughLevel, limit: tabKey === 'products' || tabKey === 'product_detail' || tabKey === 'idle' ? productKpiFetchLimit : 50000, - filters: filterState.filters + filters: filterState.filters, + sortBy: sortState.sortBy, + descending: sortState.descending } if (tabKey === 'product_detail') { params.urunAnaGrubu = selectedDetailMainGroup.value @@ -4635,7 +4645,8 @@ async function loadBackendGroupedRows (options = {}) { const startedAt = performance.now() const rows = await performanceStore.fetchGroupedRows(params, { force: options.force === true }) const currentFilterState = backendGroupedFilterState(activeTab.value) - const currentKey = `${activeTab.value}|${selectedExpandThroughLevel.value}|${selectedDetailMainGroup.value || ''}|${JSON.stringify(currentFilterState.filters)}` + const currentSortState = tableSortState(activeTab.value) + const currentKey = `${activeTab.value}|${selectedExpandThroughLevel.value}|${selectedDetailMainGroup.value || ''}|${JSON.stringify(currentFilterState.filters)}|${currentSortState.sortBy}|${currentSortState.descending ? 1 : 0}` if (requestKey !== currentKey) return const normalizedRows = Array.isArray(rows) ? rows.map(row => normalizeGroupedDisplayRow(tabKey, row)) : [] backendGroupedRows.value = { @@ -4657,17 +4668,7 @@ async function loadBackendGroupedRows (options = {}) { function normalizeGroupedDisplayRow (tabKey, row) { const nextRow = { ...row } normalizeGroupedProductHierarchyValues(nextRow) - if (tabKey === 'order_product_customers' || tabKey === 'order_market_details') { - return withOrderPeriodScores(nextRow) - } - const next = withPeriodScores(withGeneralMargins(withProductMargins(nextRow))) - if (tabKey !== 'products' && tabKey !== 'product_detail' && tabKey !== 'idle') { - next.customer_score_90d = Number(next.customer_score_90d || customerSalesPeriodScore(periodMetricSource(next, '90d'))) - next.customer_score_180d = Number(next.customer_score_180d || customerSalesPeriodScore(periodMetricSource(next, '180d'))) - next.customer_score_365d = Number(next.customer_score_365d || customerSalesPeriodScore(periodMetricSource(next, '365d'))) - next.customer_score_total = Number(next.customer_score_total || customerSalesPeriodScore(periodMetricSource(next, 'total'))) - } - return next + return nextRow } function normalizeGroupedProductHierarchyValues (row) { @@ -5048,6 +5049,11 @@ watch(activeTab, () => { setupTopScrollbarSync() }) +watch(activeBackendGroupedSortSignature, () => { + if (!backendGroupedSupportedTab(activeTab.value) || loading.value) return + scheduleLoadBackendGroupedRows() +}) + watch(activeDisplayedTableRows, () => { setupTopScrollbarSync() }, { flush: 'post' }) diff --git a/ui/src/stores/productPerformanceStore.js b/ui/src/stores/productPerformanceStore.js index 7f2e12b..3a74da3 100644 --- a/ui/src/stores/productPerformanceStore.js +++ b/ui/src/stores/productPerformanceStore.js @@ -90,6 +90,8 @@ export const useProductPerformanceStore = defineStore('product-performance-store expandedKeys: normalizedList(params.expandedKeys, true), expandThroughLevel: Number(params.expandThroughLevel ?? params.expand_through_level ?? -1), filters: normalizedFilterMap(params.filters), + sortBy: String(params.sortBy || params.sort_by || ''), + descending: params.descending !== false, limit: Number(params.limit || 0) }) }, @@ -134,13 +136,17 @@ export const useProductPerformanceStore = defineStore('product-performance-store expanded_keys: Array.isArray(params.expandedKeys) ? params.expandedKeys : String(params.expandedKeys || '').split(',').filter(Boolean), expand_through_level: Number(params.expandThroughLevel ?? params.expand_through_level ?? -1), filters: normalizedFilterMap(params.filters), + sort_by: String(params.sortBy || params.sort_by || ''), + descending: params.descending !== false, limit: params.limit }, { params: { mode: params.mode, limit: params.limit, urun_ana_grubu: params.urunAnaGrubu || params.urun_ana_grubu || '', - expand_through_level: Number(params.expandThroughLevel ?? params.expand_through_level ?? -1) + expand_through_level: Number(params.expandThroughLevel ?? params.expand_through_level ?? -1), + sort_by: String(params.sortBy || params.sort_by || ''), + descending: params.descending !== false }, timeout: 90000 }).then(resp => { @@ -154,10 +160,11 @@ export const useProductPerformanceStore = defineStore('product-performance-store }) return rows }).catch(err => { - console.warn(`${logPrefix} request failed`, { + const message = err?.response?.data || err?.message || String(err || '') + console.warn(`${logPrefix} request failed: ${message}`, { mode: params.mode, elapsedSec: Number(((performance.now() - startedAt) / 1000).toFixed(2)), - message: err?.response?.data || err?.message || err + message }) throw err }).finally(() => {