From 48bde09c2c96fd3284f4fba930e5bcede8af2170 Mon Sep 17 00:00:00 2001 From: M_Kececi Date: Sun, 5 Jul 2026 02:57:52 +0300 Subject: [PATCH] Fix product performance grouped JSON build --- svc/cmd/product_performance_refresh/main.go | 3 +- svc/product_performance_scheduler.go | 96 ++++- svc/queries/product_performance.go | 388 +++++++++++++++++- .../pages/ProductPerformanceProfitability.vue | 145 ++++--- 4 files changed, 562 insertions(+), 70 deletions(-) diff --git a/svc/cmd/product_performance_refresh/main.go b/svc/cmd/product_performance_refresh/main.go index 843c36d..6ee7b00 100644 --- a/svc/cmd/product_performance_refresh/main.go +++ b/svc/cmd/product_performance_refresh/main.go @@ -66,10 +66,11 @@ func main() { log.Fatalf("refresh failed: %v", err) } - fmt.Printf("product performance refresh done: sales=%d stock=%d kpi=%d\n", + fmt.Printf("product performance refresh done: sales=%d stock=%d kpi=%d snapshots=%d\n", result.SalesRows, result.StockRows, result.KpiRows, + result.SnapshotRows, ) } diff --git a/svc/product_performance_scheduler.go b/svc/product_performance_scheduler.go index 673815b..1176397 100644 --- a/svc/product_performance_scheduler.go +++ b/svc/product_performance_scheduler.go @@ -26,6 +26,12 @@ func startProductPerformanceScheduler(pgDB *sql.DB) { deltaHHMM := productPerformanceEnvString("PRODUCT_PERFORMANCE_DELTA_HHMM", "02:00") deltaTimeoutHours := productPerformanceEnvInt("PRODUCT_PERFORMANCE_DELTA_TIMEOUT_HOURS", 6, 1, 24) runOnStartup := productPerformanceEnvBool("PRODUCT_PERFORMANCE_DELTA_RUN_ON_STARTUP", false) + fullEnabled := productPerformanceEnvBool("PRODUCT_PERFORMANCE_FULL_ENABLED", false) + fullWeekday := productPerformanceEnvWeekday("PRODUCT_PERFORMANCE_FULL_WEEKDAY", time.Saturday) + fullHHMM := productPerformanceEnvString("PRODUCT_PERFORMANCE_FULL_HHMM", "16:00") + fullStartDate := productPerformanceEnvDate("PRODUCT_PERFORMANCE_FULL_START_DATE", time.Date(2022, 1, 1, 0, 0, 0, 0, time.Local)) + fullTimeoutHours := productPerformanceEnvInt("PRODUCT_PERFORMANCE_FULL_TIMEOUT_HOURS", 18, 1, 72) + fullRunOnStartup := productPerformanceEnvBool("PRODUCT_PERFORMANCE_FULL_RUN_ON_STARTUP", false) var running int32 runDelta := func(reason string) { @@ -49,12 +55,40 @@ func startProductPerformanceScheduler(pgDB *sql.DB) { log.Printf("[ProductPerformanceJob] error (%s): %v", reason, err) return } - log.Printf("[ProductPerformanceJob] ok (%s): mode=%s stage=%s start=%s end=%s sales=%d stock=%d kpi=%d duration_ms=%d", - reason, result.Mode, result.Stage, result.StartDate, result.EndDate, result.SalesRows, result.StockRows, result.KpiRows, result.DurationMS) + log.Printf("[ProductPerformanceJob] ok (%s): mode=%s stage=%s start=%s end=%s sales=%d stock=%d kpi=%d snapshots=%d duration_ms=%d", + reason, result.Mode, result.Stage, result.StartDate, result.EndDate, result.SalesRows, result.StockRows, result.KpiRows, result.SnapshotRows, result.DurationMS) + } + runFull := func(reason string) { + if !atomic.CompareAndSwapInt32(&running, 0, 1) { + log.Printf("[ProductPerformanceJob] skip (%s): already running", reason) + return + } + defer atomic.StoreInt32(&running, 0) + + endDate := time.Now() + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(fullTimeoutHours)*time.Hour) + defer cancel() + + result, err := queries.RefreshProductPerformance(ctx, pgDB, queries.ProductPerformanceRefreshRequest{ + Mode: "full", + Stage: "all", + StartDate: fullStartDate, + EndDate: endDate, + }) + if err != nil { + log.Printf("[ProductPerformanceJob] error (%s): %v", reason, err) + return + } + log.Printf("[ProductPerformanceJob] ok (%s): mode=%s stage=%s start=%s end=%s sales=%d stock=%d kpi=%d snapshots=%d duration_ms=%d", + reason, result.Mode, result.Stage, result.StartDate, result.EndDate, result.SalesRows, result.StockRows, result.KpiRows, result.SnapshotRows, result.DurationMS) } log.Printf("[ProductPerformanceJob] scheduled daily_delta=%s lookback_days=%d run_delta_on_startup=%t", deltaHHMM, deltaDays, runOnStartup) + if fullEnabled { + log.Printf("[ProductPerformanceJob] scheduled weekly_full=%s %s start_date=%s timeout_hours=%d run_full_on_startup=%t", + fullWeekday.String(), fullHHMM, fullStartDate.Format("2006-01-02"), fullTimeoutHours, fullRunOnStartup) + } go func() { if runOnStartup { @@ -69,6 +103,21 @@ func startProductPerformanceScheduler(pgDB *sql.DB) { runDelta("daily-delta") } }() + if fullEnabled { + go func() { + if fullRunOnStartup { + time.Sleep(30 * time.Second) + runFull("startup-full") + } + + for { + next := productPerformanceNextWeekly(time.Now(), fullWeekday, fullHHMM) + log.Printf("[ProductPerformanceJob] weekly full next_at=%s in=%s", next.Format(time.RFC3339), time.Until(next).Round(time.Second)) + time.Sleep(time.Until(next)) + runFull("weekly-full") + } + }() + } } func productPerformanceNextDaily(now time.Time, hhmm string) time.Time { @@ -80,6 +129,16 @@ func productPerformanceNextDaily(now time.Time, hhmm string) time.Time { return next } +func productPerformanceNextWeekly(now time.Time, weekday time.Weekday, hhmm string) time.Time { + hour, minute := productPerformanceParseHHMM(hhmm, 16, 0) + daysUntil := (int(weekday) - int(now.Weekday()) + 7) % 7 + next := time.Date(now.Year(), now.Month(), now.Day(), hour, minute, 0, 0, now.Location()).AddDate(0, 0, daysUntil) + if !next.After(now) { + next = next.AddDate(0, 0, 7) + } + return next +} + func productPerformanceParseHHMM(raw string, fallbackHour, fallbackMinute int) (int, int) { parts := strings.Split(strings.TrimSpace(raw), ":") if len(parts) != 2 { @@ -120,3 +179,36 @@ func productPerformanceEnvBool(name string, fallback bool) bool { } return raw == "1" || raw == "true" || raw == "on" || raw == "yes" } + +func productPerformanceEnvDate(name string, fallback time.Time) time.Time { + raw := strings.TrimSpace(os.Getenv(name)) + if raw == "" { + return fallback + } + parsed, err := time.ParseInLocation("2006-01-02", raw, time.Local) + if err != nil { + return fallback + } + return parsed +} + +func productPerformanceEnvWeekday(name string, fallback time.Weekday) time.Weekday { + switch strings.ToLower(strings.TrimSpace(os.Getenv(name))) { + case "sunday", "sun", "pazar", "0": + return time.Sunday + case "monday", "mon", "pazartesi", "1": + return time.Monday + case "tuesday", "tue", "sali", "salı", "2": + return time.Tuesday + case "wednesday", "wed", "carsamba", "çarşamba", "3": + return time.Wednesday + case "thursday", "thu", "persembe", "perşembe", "4": + return time.Thursday + case "friday", "fri", "cuma", "5": + return time.Friday + case "saturday", "sat", "cumartesi", "6": + return time.Saturday + default: + return fallback + } +} diff --git a/svc/queries/product_performance.go b/svc/queries/product_performance.go index b7b5e27..3e78366 100644 --- a/svc/queries/product_performance.go +++ b/svc/queries/product_performance.go @@ -8,6 +8,7 @@ import ( "encoding/json" "fmt" "log" + "os" "sort" "strings" "time" @@ -39,16 +40,19 @@ type ProductPerformanceRefreshRequest struct { } type ProductPerformanceRefreshResult struct { - Mode string `json:"mode"` - Stage string `json:"stage"` - StartDate string `json:"start_date"` - EndDate string `json:"end_date"` - SalesRows int `json:"sales_rows"` - StockRows int `json:"stock_rows"` - KpiRows int `json:"kpi_rows"` - DurationMS int64 `json:"duration_ms"` + Mode string `json:"mode"` + Stage string `json:"stage"` + StartDate string `json:"start_date"` + EndDate string `json:"end_date"` + SalesRows int `json:"sales_rows"` + StockRows int `json:"stock_rows"` + KpiRows int `json:"kpi_rows"` + SnapshotRows int `json:"snapshot_rows"` + DurationMS int64 `json:"duration_ms"` } +type productPerformanceSnapshotBypassKey struct{} + func productPerformanceStockQuery() string { return ` ;WITH ActiveWarehouses AS ( @@ -561,6 +565,23 @@ WHERE btrim(askili_yan) = '-'`, `ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS unit_profit_base_90d NUMERIC(18,6) NOT NULL DEFAULT 0`, `ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS unit_profit_base_180d NUMERIC(18,6) NOT NULL DEFAULT 0`, `ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS market_count_90d INTEGER NOT NULL DEFAULT 0`, + ` +CREATE TABLE IF NOT EXISTS mk_product_performance_report_snapshot ( + report_key TEXT NOT NULL, + row_order INTEGER NOT NULL, + payload JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT pk_mk_product_performance_report_snapshot PRIMARY KEY (report_key, row_order) +)`, + `CREATE INDEX IF NOT EXISTS ix_mk_product_perf_report_snapshot_key ON mk_product_performance_report_snapshot (report_key, row_order)`, + ` +CREATE TABLE IF NOT EXISTS mk_product_performance_report_snapshot_meta ( + report_key TEXT PRIMARY KEY, + 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 { if _, err := pg.Exec(stmt); err != nil { @@ -570,6 +591,264 @@ WHERE btrim(askili_yan) = '-'`, return nil } +func productPerformanceSnapshotBypass(ctx context.Context) context.Context { + return context.WithValue(ctx, productPerformanceSnapshotBypassKey{}, true) +} + +func productPerformanceUseSnapshot(ctx context.Context) bool { + v, _ := ctx.Value(productPerformanceSnapshotBypassKey{}).(bool) + return !v +} + +func productPerformanceLiveFallbackEnabled() bool { + raw := strings.TrimSpace(strings.ToLower(os.Getenv("PRODUCT_PERFORMANCE_LIVE_FALLBACK"))) + return raw == "1" || raw == "true" || raw == "on" || raw == "yes" +} + +func productPerformanceSnapshotKey(parts ...string) string { + cleaned := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.ToLower(strings.TrimSpace(part)) + if part != "" { + cleaned = append(cleaned, part) + } + } + return strings.Join(cleaned, ":") +} + +func loadProductPerformanceSnapshotRows[T any](ctx context.Context, pg *sql.DB, reportKey string, limit int) ([]T, 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([]T, 0, limit) + for rows.Next() { + var raw []byte + if err := rows.Scan(&raw); err != nil { + return nil, false, err + } + var item T + if err := json.Unmarshal(raw, &item); err != nil { + return nil, false, err + } + 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) + if err != nil || !ok || len(rows) == 0 { + return zero, ok, err + } + return rows[0], true, nil +} + +func saveProductPerformanceSnapshotRows[T any](ctx context.Context, pg *sql.DB, reportKey string, rows []T, 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_report_snapshot WHERE report_key = $1`, reportKey); err != nil { + return err + } + stmt, err := tx.PrepareContext(ctx, ` +INSERT INTO mk_product_performance_report_snapshot (report_key, row_order, payload, updated_at) +VALUES ($1, $2, $3, now()) +`) + if err != nil { + return err + } + defer stmt.Close() + for i, row := range rows { + raw, err := json.Marshal(row) + if err != nil { + return err + } + if _, err := stmt.ExecContext(ctx, reportKey, i, raw); err != nil { + return err + } + } + if _, err := tx.ExecContext(ctx, ` +INSERT INTO mk_product_performance_report_snapshot_meta (report_key, row_count, refreshed_at, duration_ms) +VALUES ($1, $2, now(), $3) +ON CONFLICT (report_key) DO UPDATE SET + row_count = EXCLUDED.row_count, + refreshed_at = EXCLUDED.refreshed_at, + duration_ms = EXCLUDED.duration_ms +`, reportKey, 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 + } + ctx = productPerformanceSnapshotBypass(ctx) + total := 0 + + save := func(reportKey string, rows any, started time.Time) error { + switch v := rows.(type) { + case []models.ProductPerformanceSummary: + total += len(v) + return saveProductPerformanceSnapshotRows(ctx, pg, reportKey, v, started) + case []models.ProductPerformanceRow: + total += len(v) + return saveProductPerformanceSnapshotRows(ctx, pg, reportKey, v, started) + case []models.ProductPerformanceGeneralRow: + total += len(v) + return saveProductPerformanceSnapshotRows(ctx, pg, reportKey, v, started) + case []models.ProductPerformanceOrderAnalysisRow: + total += len(v) + return saveProductPerformanceSnapshotRows(ctx, pg, reportKey, v, started) + case []models.ProductPerformanceOrderGroupRow: + total += len(v) + return saveProductPerformanceSnapshotRows(ctx, pg, reportKey, v, started) + case []models.ProductPerformanceOrderProductCustomerRow: + total += len(v) + return saveProductPerformanceSnapshotRows(ctx, pg, reportKey, v, started) + case []models.ProductPerformanceOrderMarketDetailRow: + total += len(v) + return saveProductPerformanceSnapshotRows(ctx, pg, reportKey, v, started) + case []models.ProductPerformanceMarketRow: + total += len(v) + return saveProductPerformanceSnapshotRows(ctx, pg, reportKey, v, started) + case []models.ProductPerformanceCountryRow: + total += len(v) + return saveProductPerformanceSnapshotRows(ctx, pg, reportKey, v, started) + case []models.ProductPerformanceCustomerRow: + total += len(v) + return saveProductPerformanceSnapshotRows(ctx, pg, reportKey, v, started) + case []models.ProductPerformanceSalesBreakdownRow: + total += len(v) + return saveProductPerformanceSnapshotRows(ctx, pg, reportKey, v, started) + default: + return fmt.Errorf("unsupported product performance snapshot payload %s", reportKey) + } + } + + started := time.Now() + if summary, err := GetProductPerformanceSummary(ctx, pg); err != nil { + return total, err + } else if err := save(productPerformanceSnapshotKey("summary"), []models.ProductPerformanceSummary{summary}, started); err != nil { + return total, err + } + started = time.Now() + if rows, _, err := ListProductPerformance(ctx, pg, ProductPerformanceFilters{Limit: 50000, Page: 1, SortBy: "performance_score", Descending: true}); err != nil { + return total, err + } else if err := save(productPerformanceSnapshotKey("products"), rows, started); err != nil { + return total, err + } + started = time.Now() + if rows, err := ListProductPerformanceGeneral(ctx, pg, 50000); err != nil { + return total, err + } else if err := save(productPerformanceSnapshotKey("general"), rows, started); err != nil { + return total, err + } + started = time.Now() + if rows, err := ListProductPerformanceOrderAnalysis(ctx, pg, 50000); err != nil { + return total, err + } else if err := save(productPerformanceSnapshotKey("orders"), rows, started); err != nil { + return total, err + } + for _, mode := range []string{"market", "customer"} { + started = time.Now() + rows, err := ListProductPerformanceOrderGroups(ctx, pg, mode, 50000) + if err != nil { + return total, err + } + if err := save(productPerformanceSnapshotKey("order-groups", mode), rows, started); err != nil { + return total, err + } + } + started = time.Now() + if rows, err := ListProductPerformanceOrderProductCustomers(ctx, pg, 50000); err != nil { + return total, err + } else if err := save(productPerformanceSnapshotKey("order-product-customers"), rows, started); err != nil { + return total, err + } + started = time.Now() + if rows, err := ListProductPerformanceOrderMarketDetails(ctx, pg, 50000); err != nil { + return total, err + } else if err := save(productPerformanceSnapshotKey("order-market-details"), rows, started); err != nil { + return total, err + } + started = time.Now() + if rows, err := ListProductPerformanceMarkets(ctx, pg, 50000); err != nil { + return total, err + } else if err := save(productPerformanceSnapshotKey("markets"), rows, started); err != nil { + return total, err + } + started = time.Now() + if rows, err := ListProductPerformanceCountries(ctx, pg, 50000); err != nil { + return total, err + } else if err := save(productPerformanceSnapshotKey("countries"), rows, started); err != nil { + return total, err + } + for _, mode := range []string{"market_customer", "country_customer", "market_country_customer"} { + started = time.Now() + rows, err := ListProductPerformanceCustomers(ctx, pg, mode, 50000) + if err != nil { + return total, err + } + if err := save(productPerformanceSnapshotKey("customers", mode), rows, started); err != nil { + return total, err + } + } + for _, mode := range []string{"color_yaka_market_customer", "product_country_segment_market_customer", "market_customer_product", "country_segment_market_customer_product"} { + started = time.Now() + rows, err := ListProductPerformanceSalesBreakdown(ctx, pg, mode, 50000) + if err != nil { + return total, err + } + if err := save(productPerformanceSnapshotKey("sales-breakdown", mode), rows, started); err != nil { + return total, err + } + } + return total, nil +} + func RefreshProductPerformance(ctx context.Context, pg *sql.DB, req ProductPerformanceRefreshRequest) (ProductPerformanceRefreshResult, error) { started := time.Now() stage := productPerformanceRefreshStage(req.Stage) @@ -689,6 +968,7 @@ func RefreshProductPerformance(ctx context.Context, pg *sql.DB, req ProductPerfo } var kpiRows int + var snapshotRows int if shouldRun("kpi") { if err := runStage("kpi", func(tx *sql.Tx) error { log.Printf("[ProductPerformanceRefresh] kpi rebuild start") @@ -702,20 +982,28 @@ func RefreshProductPerformance(ctx context.Context, pg *sql.DB, req ProductPerfo }); err != nil { return ProductPerformanceRefreshResult{}, err } + log.Printf("[ProductPerformanceRefresh] report snapshots rebuild start") + rows, err := RebuildProductPerformanceReportSnapshots(ctx, pg) + if err != nil { + return ProductPerformanceRefreshResult{}, err + } + snapshotRows = rows + log.Printf("[ProductPerformanceRefresh] report snapshots rebuild done rows=%d elapsed=%s", snapshotRows, time.Since(started).Round(time.Second)) } else { log.Printf("[ProductPerformanceRefresh] kpi skipped stage=%s", stage) } log.Printf("[ProductPerformanceRefresh] refresh done total_elapsed=%s", time.Since(started).Round(time.Second)) return ProductPerformanceRefreshResult{ - Mode: mode, - Stage: stage, - StartDate: req.StartDate.Format("2006-01-02"), - EndDate: req.EndDate.Format("2006-01-02"), - SalesRows: salesRows, - StockRows: stockRows, - KpiRows: kpiRows, - DurationMS: time.Since(started).Milliseconds(), + Mode: mode, + Stage: stage, + StartDate: req.StartDate.Format("2006-01-02"), + EndDate: req.EndDate.Format("2006-01-02"), + SalesRows: salesRows, + StockRows: stockRows, + KpiRows: kpiRows, + SnapshotRows: snapshotRows, + DurationMS: time.Since(started).Milliseconds(), }, nil } @@ -1168,6 +1456,13 @@ func ListProductPerformance(ctx context.Context, pg *sql.DB, f ProductPerformanc if page <= 0 { page = 1 } + if !productPerformanceHasServerFilters(f) { + if rows, ok, err := loadProductPerformanceSnapshotRows[models.ProductPerformanceRow](ctx, pg, productPerformanceSnapshotKey("products"), limit); err != nil { + return nil, 0, err + } else if ok { + return rows, len(rows), nil + } + } where, args := productPerformanceWhere(f) countQuery := `SELECT COUNT(*) FROM mk_product_performance_kpi_daily WHERE kpi_date = (SELECT MAX(kpi_date) FROM mk_product_performance_kpi_daily)` + where var total int @@ -1237,6 +1532,11 @@ func GetProductPerformanceSummary(ctx context.Context, pg *sql.DB) (models.Produ if err := EnsureProductPerformanceTables(pg); err != nil { return models.ProductPerformanceSummary{}, err } + if summary, ok, err := loadProductPerformanceSnapshotItem[models.ProductPerformanceSummary](ctx, pg, productPerformanceSnapshotKey("summary")); err != nil { + return models.ProductPerformanceSummary{}, err + } else if ok { + return summary, nil + } var s models.ProductPerformanceSummary err := pg.QueryRowContext(ctx, ` WITH Latest AS ( @@ -1307,6 +1607,11 @@ func ListProductPerformanceGeneral(ctx context.Context, pg *sql.DB, limit int) ( } else if limit > 50000 { limit = 50000 } + if rows, ok, err := loadProductPerformanceSnapshotRows[models.ProductPerformanceGeneralRow](ctx, pg, productPerformanceSnapshotKey("general"), limit); err != nil { + return nil, err + } else if ok { + return rows, nil + } rows, err := pg.QueryContext(ctx, ` WITH Bounds AS ( SELECT @@ -1615,6 +1920,11 @@ func ListProductPerformanceOrderAnalysis(ctx context.Context, pg *sql.DB, limit } else if limit > 50000 { limit = 50000 } + if rows, ok, err := loadProductPerformanceSnapshotRows[models.ProductPerformanceOrderAnalysisRow](ctx, pg, productPerformanceSnapshotKey("orders"), limit); err != nil { + return nil, err + } else if ok { + return rows, nil + } rows, err := db.MssqlDB.QueryContext(ctx, ` WITH OpenOrderLines AS ( @@ -1917,6 +2227,11 @@ func ListProductPerformanceOrderGroups(ctx context.Context, pg *sql.DB, breakdow } else if limit > 50000 { limit = 50000 } + if rows, ok, err := loadProductPerformanceSnapshotRows[models.ProductPerformanceOrderGroupRow](ctx, pg, productPerformanceSnapshotKey("order-groups", mode), limit); err != nil { + return nil, err + } else if ok { + return rows, nil + } rows, err := db.MssqlDB.QueryContext(ctx, ` WITH OpenOrderLines AS ( @@ -2153,6 +2468,11 @@ func ListProductPerformanceOrderProductCustomers(ctx context.Context, pg *sql.DB } else if limit > 50000 { limit = 50000 } + if rows, ok, err := loadProductPerformanceSnapshotRows[models.ProductPerformanceOrderProductCustomerRow](ctx, pg, productPerformanceSnapshotKey("order-product-customers"), limit); err != nil { + return nil, err + } else if ok { + return rows, nil + } rows, err := db.MssqlDB.QueryContext(ctx, ` WITH OpenOrderLines AS ( SELECT @@ -2291,6 +2611,11 @@ func ListProductPerformanceOrderMarketDetails(ctx context.Context, pg *sql.DB, l } else if limit > 50000 { limit = 50000 } + if rows, ok, err := loadProductPerformanceSnapshotRows[models.ProductPerformanceOrderMarketDetailRow](ctx, pg, productPerformanceSnapshotKey("order-market-details"), limit); err != nil { + return nil, err + } else if ok { + return rows, nil + } rows, err := db.MssqlDB.QueryContext(ctx, ` WITH OpenOrderLines AS ( SELECT @@ -2414,6 +2739,11 @@ func ListProductPerformanceMarkets(ctx context.Context, pg *sql.DB, limit int) ( } else if limit > 50000 { limit = 50000 } + if rows, ok, err := loadProductPerformanceSnapshotRows[models.ProductPerformanceMarketRow](ctx, pg, productPerformanceSnapshotKey("markets"), limit); err != nil { + return nil, err + } else if ok { + return rows, nil + } rows, err := pg.QueryContext(ctx, ` SELECT market_key, @@ -2472,6 +2802,11 @@ func ListProductPerformanceCountries(ctx context.Context, pg *sql.DB, limit int) } else if limit > 50000 { limit = 50000 } + if rows, ok, err := loadProductPerformanceSnapshotRows[models.ProductPerformanceCountryRow](ctx, pg, productPerformanceSnapshotKey("countries"), limit); err != nil { + return nil, err + } else if ok { + return rows, nil + } rows, err := pg.QueryContext(ctx, ` SELECT customer_country, @@ -2549,6 +2884,11 @@ func ListProductPerformanceCustomers(ctx context.Context, pg *sql.DB, breakdown selectMarket = "market_key" groupCols = append(groupCols, "market_key") } + if rows, ok, err := loadProductPerformanceSnapshotRows[models.ProductPerformanceCustomerRow](ctx, pg, productPerformanceSnapshotKey("customers", mode), limit); err != nil { + return nil, err + } else if ok { + return rows, nil + } query := fmt.Sprintf(` SELECT @@ -2709,6 +3049,12 @@ func ListProductPerformanceSalesBreakdown(ctx context.Context, pg *sql.DB, break groupCols = []string{"CASE WHEN btrim(COALESCE(s.urun_ilk_grubu,'')) = '-' THEN '' WHEN upper(translate(btrim(COALESCE(s.urun_ilk_grubu,'')), 'İŞĞÜÖÇ', 'ISGUOC')) IN ('YETISKIN', 'YETISKIN/GARSON', 'GARSON') THEN '' ELSE s.urun_ilk_grubu END", "s.color_code", "s.yaka_kodu", "CASE WHEN btrim(COALESCE(s.askili_yan,'')) = '-' THEN '' ELSE s.askili_yan END", "s.kategori", "s.urun_ana_grubu", "s.urun_alt_grubu", "s.product_code", "s.customer_country", "s.market_key", "s.customer_segment", "s.customer_code"} } + if rows, ok, err := loadProductPerformanceSnapshotRows[models.ProductPerformanceSalesBreakdownRow](ctx, pg, productPerformanceSnapshotKey("sales-breakdown", mode), limit); err != nil { + return nil, err + } else if ok { + return rows, nil + } + selectExpr := func(name string) string { if selected[name] { return fields[name] + " AS " + name @@ -4404,6 +4750,16 @@ func productPerformanceWhere(f ProductPerformanceFilters) (string, []any) { return strings.Join(parts, ""), args } +func productPerformanceHasServerFilters(f ProductPerformanceFilters) bool { + return strings.TrimSpace(f.Search) != "" || + strings.TrimSpace(f.ProductCode) != "" || + strings.TrimSpace(f.MarketKey) != "" || + strings.TrimSpace(f.Kategori) != "" || + strings.TrimSpace(f.Seri) != "" || + strings.TrimSpace(f.Bucket) != "" || + f.Page > 1 +} + func productPerformanceOrderBy(sortBy string, desc bool) string { allowed := map[string]string{ "product_code": "product_code", diff --git a/ui/src/pages/ProductPerformanceProfitability.vue b/ui/src/pages/ProductPerformanceProfitability.vue index 4385ee7..cefa9ee 100644 --- a/ui/src/pages/ProductPerformanceProfitability.vue +++ b/ui/src/pages/ProductPerformanceProfitability.vue @@ -1611,10 +1611,10 @@ const selectedExpandLevelKeysByTab = reactive({ product_detail: ['urun_alt_grubu'], sales_color_yaka_market_customer: ['color_yaka'], idle: ['urun_ilk_grubu', 'askili_yan', 'kategori'], - sales_product_country_segment_market_customer: ['market_key', 'customer_code', 'customer_name', 'urun_ilk_grubu', 'askili_yan'], + sales_product_country_segment_market_customer: ['urun_ilk_grubu', 'askili_yan', 'kategori'], sales_market_customer_product: ['market_key', 'customer_code', 'customer_name', 'urun_ilk_grubu', 'askili_yan'], - sales_country_segment_market_customer_product: ['market_key', 'customer_code', 'customer_name', 'urun_ilk_grubu', 'askili_yan'], - order_product_customers: ['market_key', 'customer_code', 'customer_name', 'urun_ilk_grubu', 'askili_yan'], + sales_country_segment_market_customer_product: ['country', 'customer_segment', 'market_key'], + order_product_customers: ['urun_ilk_grubu', 'askili_yan', 'kategori'], order_market_details: ['market_key', 'customer_code', 'customer_name', 'urun_ilk_grubu', 'askili_yan'] }) const productKpiFetchLimit = 50000 @@ -1623,6 +1623,7 @@ const maxBulkExpandKeys = 1000 const maxBackendExpandedKeys = 1000 const tableVirtualSliceSize = 160 const productThumbVirtualItemSize = 236 +const maxRenderedGroupedRows = 2500 const maxAutoExpandLevelByTab = { products: 8, product_detail: 8, @@ -2554,16 +2555,18 @@ const tabGroupLevels = { { key: 'color_yaka', label: 'Renk/Yaka' } ], sales_product_country_segment_market_customer: [ - { key: 'market_key', label: 'Piyasa' }, - { key: 'customer_code', label: 'Müşteri Kodu' }, - { key: 'customer_name', label: 'Müşteri' }, { key: 'urun_ilk_grubu', label: 'Ürün İlk Grubu' }, { key: 'askili_yan', label: 'Askılı/Yan' }, { key: 'kategori', label: 'Kategori' }, { key: 'urun_ana_grubu', label: 'Ürün Ana Grubu' }, { key: 'urun_alt_grubu', label: 'Ürün Alt Grubu' }, { key: 'product_code', label: 'Ürün' }, - { key: 'color_yaka', label: 'Renk/Yaka' } + { key: 'color_yaka', label: 'Renk/Yaka' }, + { key: 'country', label: 'Ülke' }, + { key: 'customer_segment', label: 'Segment' }, + { key: 'market_key', label: 'Piyasa' }, + { key: 'customer_code', label: 'Müşteri Kodu' }, + { key: 'customer_name', label: 'Müşteri' } ], sales_market_customer_product: [ { key: 'market_key', label: 'Piyasa' }, @@ -2578,6 +2581,8 @@ const tabGroupLevels = { { key: 'color_yaka', label: 'Renk/Yaka' } ], sales_country_segment_market_customer_product: [ + { key: 'country', label: 'Ülke' }, + { key: 'customer_segment', label: 'Segment' }, { key: 'market_key', label: 'Piyasa' }, { key: 'customer_code', label: 'Müşteri Kodu' }, { key: 'customer_name', label: 'Müşteri' }, @@ -2590,16 +2595,16 @@ const tabGroupLevels = { { key: 'color_yaka', label: 'Renk/Yaka' } ], order_product_customers: [ - { key: 'market_key', label: 'Piyasa' }, - { key: 'customer_code', label: 'Müşteri Kodu' }, - { key: 'customer_name', label: 'Müşteri' }, { key: 'urun_ilk_grubu', label: 'Ürün İlk Grubu' }, { key: 'askili_yan', label: 'Askılı/Yan' }, { key: 'kategori', label: 'Kategori' }, { key: 'urun_ana_grubu', label: 'Ürün Ana Grubu' }, { key: 'urun_alt_grubu', label: 'Ürün Alt Grubu' }, { key: 'product_code', label: 'Ürün' }, - { key: 'color_yaka', label: 'Renk/Yaka' } + { key: 'color_yaka', label: 'Renk/Yaka' }, + { key: 'market_key', label: 'Piyasa' }, + { key: 'customer_code', label: 'Müşteri Kodu' }, + { key: 'customer_name', label: 'Müşteri' } ], order_market_details: [ { key: 'market_key', label: 'Piyasa' }, @@ -2660,22 +2665,6 @@ const fullExpandThroughLevel = computed(() => { return Math.min(lastGroupLevel, maxAutoExpandThroughLevelForTab()) }) -const tableFilterSourceMap = computed(() => ({ - products: { rows: filterSourceRowsForTab('products', rows.value), columns: productColumns.value }, - product_detail: { rows: filterSourceRowsForTab('product_detail', detailProductRows.value), columns: productDetailColumns.value }, - general: { rows: generalRows.value, columns: generalColumns }, - order_product_customers: { rows: filterSourceRowsForTab('order_product_customers', orderProductCustomerRows.value), columns: orderProductCustomerColumns }, - order_market_details: { rows: filterSourceRowsForTab('order_market_details', orderMarketDetailRows.value), columns: orderMarketDetailColumns }, - idle: { rows: filterSourceRowsForTab('idle', idleRows.value), columns: idleColumns }, - markets: { rows: marketRows.value, columns: marketColumns }, - countries: { rows: countryRows.value, columns: countryColumns }, - customers: { rows: customerRows.value, columns: customerColumns }, - sales_color_yaka_market_customer: { rows: filterSourceRowsForTab('sales_color_yaka_market_customer', salesBreakdownRows.sales_color_yaka_market_customer), columns: visibleSalesBreakdownColumns.value }, - sales_product_country_segment_market_customer: { rows: filterSourceRowsForTab('sales_product_country_segment_market_customer', salesBreakdownRows.sales_product_country_segment_market_customer), columns: visibleSalesBreakdownColumns.value }, - sales_market_customer_product: { rows: filterSourceRowsForTab('sales_market_customer_product', salesBreakdownRows.sales_market_customer_product), columns: visibleSalesBreakdownColumns.value }, - sales_country_segment_market_customer_product: { rows: filterSourceRowsForTab('sales_country_segment_market_customer_product', salesBreakdownRows.sales_country_segment_market_customer_product), columns: visibleSalesBreakdownColumns.value } -})) - const filteredProductRows = computed(() => filterRowsForTable('products', rows.value, productColumns.value)) const isProductKpiTab = computed(() => activeTab.value === 'products' || activeTab.value === 'product_detail') const activeProductTableKey = computed(() => activeTab.value === 'product_detail' ? 'product_detail' : 'products') @@ -2706,7 +2695,7 @@ const activeGroupSourceRows = computed(() => { const columnFilterOptionMap = computed(() => { const out = {} const tableKey = isProductKpiTab.value ? activeProductTableKey.value : activeTab.value - const source = tableFilterSourceMap.value[tableKey] + const source = tableFilterSource(tableKey) if (!source) return out out[tableKey] = {} for (const col of source.columns) { @@ -2730,7 +2719,7 @@ const columnFilterOptionMap = computed(() => { const productTableRows = computed(() => { const out = [] - appendGroupRows(out, filteredProductRows.value, 0, ['tab:products'], groupLevels) + appendGroupRows(out, filteredProductRows.value, 0, ['tab:products'], groupLevels, 'products') return out }) const detailProductTableRows = computed(() => buildGroupedTableRows('product_detail', filteredDetailProductRows.value)) @@ -2813,13 +2802,14 @@ const allProductGroupsExpanded = computed(() => { return selectedExpandThroughLevel.value >= fullExpandThroughLevel.value }) -function appendGroupRows (out, sourceRows, level, parentKeys, levels = groupLevels) { - appendGroupRowsAt(out, sourceRows, level, 0, parentKeys, levels) +function appendGroupRows (out, sourceRows, level, parentKeys, levels = groupLevels, tableKey = activeTab.value) { + appendGroupRowsAt(out, sourceRows, level, 0, parentKeys, levels, tableKey) } -function appendGroupRowsAt (out, sourceRows, level, visualLevel, parentKeys, levels = groupLevels) { +function appendGroupRowsAt (out, sourceRows, level, visualLevel, parentKeys, levels = groupLevels, tableKey = activeTab.value) { + if (out.length >= maxRenderedGroupedRows) return if (level >= levels.length) { - out.push(...sortProductLeafRows(sourceRows)) + out.push(...sortLeafRowsForTable(tableKey, sourceRows).slice(0, Math.max(0, maxRenderedGroupedRows - out.length))) return } @@ -2832,16 +2822,17 @@ function appendGroupRowsAt (out, sourceRows, level, visualLevel, parentKeys, lev } Array.from(grouped.entries()) - .sort((a, b) => a[0].localeCompare(b[0], 'tr')) + .sort((a, b) => compareGroupedBucketsForTable(tableKey, a, b, groupDef)) .forEach(([value, groupRows]) => { + if (out.length >= maxRenderedGroupedRows) return if (shouldSkipGroupValue(groupDef.key, value)) { - appendGroupRowsAt(out, groupRows, level + 1, visualLevel, parentKeys, levels) + appendGroupRowsAt(out, groupRows, level + 1, visualLevel, parentKeys, levels, tableKey) return } const key = [...parentKeys, `${groupDef.key}:${value}`].join('|') out.push(makeGroupRow(key, visualLevel, groupDef, value, groupRows)) if (isGroupExpanded(key)) { - appendGroupRowsAt(out, groupRows, level + 1, visualLevel + 1, [...parentKeys, `${groupDef.key}:${value}`], levels) + appendGroupRowsAt(out, groupRows, level + 1, visualLevel + 1, [...parentKeys, `${groupDef.key}:${value}`], levels, tableKey) } }) } @@ -2849,7 +2840,7 @@ function appendGroupRowsAt (out, sourceRows, level, visualLevel, parentKeys, lev function buildGroupedTableRows (tableKey, sourceRows) { const levels = tabGroupLevels[tableKey] || groupLevels const out = [] - appendGroupRows(out, sourceRows, 0, [`tab:${tableKey}`], levels) + appendGroupRows(out, sourceRows, 0, [`tab:${tableKey}`], levels, tableKey) return out } @@ -2863,6 +2854,22 @@ function filterSourceRowsForTab (tabKey, fallbackRows) { return fallbackRows } +function tableFilterSource (tableKey) { + if (tableKey === 'products') return { rows: filterSourceRowsForTab('products', rows.value), columns: productColumns.value } + if (tableKey === 'product_detail') return { rows: filterSourceRowsForTab('product_detail', detailProductRows.value), columns: productDetailColumns.value } + if (tableKey === 'general') return { rows: generalRows.value, columns: generalColumns } + if (tableKey === 'order_product_customers') return { rows: filterSourceRowsForTab('order_product_customers', orderProductCustomerRows.value), columns: orderProductCustomerColumns } + if (tableKey === 'order_market_details') return { rows: filterSourceRowsForTab('order_market_details', orderMarketDetailRows.value), columns: orderMarketDetailColumns } + if (tableKey === 'idle') return { rows: filterSourceRowsForTab('idle', idleRows.value), columns: idleColumns } + if (tableKey === 'markets') return { rows: marketRows.value, columns: marketColumns } + if (tableKey === 'countries') return { rows: countryRows.value, columns: countryColumns } + if (tableKey === 'customers') return { rows: customerRows.value, columns: customerColumns } + if (salesBreakdownTabKeys.includes(tableKey)) { + return { rows: filterSourceRowsForTab(tableKey, salesBreakdownRows[tableKey] || []), columns: visibleSalesBreakdownColumns.value } + } + return null +} + function columnsForTableKey (tabKey) { if (tabKey === 'products') return productColumns.value if (tabKey === 'product_detail') return productDetailColumns.value @@ -2915,6 +2922,45 @@ function sortProductLeafRows (sourceRows) { }) } +function sortLeafRowsForTable (tableKey, sourceRows) { + const { sortBy, descending } = tableSortState(tableKey) + if (!sortBy) return sortProductLeafRows(sourceRows) + return sortFlatTableRows(sourceRows, sortBy, descending) +} + +function tableSortState (tableKey) { + const pagination = tablePagination[tableKey] || {} + return { + sortBy: pagination.sortBy || '', + descending: pagination.descending !== false + } +} + +function compareGroupedBucketsForTable (tableKey, left, right, groupDef) { + const [leftLabel, leftRows] = left + const [rightLabel, rightRows] = right + const { sortBy, descending } = tableSortState(tableKey) + if (!sortBy || sortBy === groupDef.key) { + return String(leftLabel).localeCompare(String(rightLabel), 'tr', { numeric: true, sensitivity: 'base' }) + } + + const leftValue = groupedBucketSortValue(leftRows, sortBy) + const rightValue = groupedBucketSortValue(rightRows, sortBy) + const cmp = compareColumnSortValues(leftValue, rightValue) + if (cmp !== 0) return descending ? -cmp : cmp + return String(leftLabel).localeCompare(String(rightLabel), 'tr', { numeric: true, sensitivity: 'base' }) +} + +function groupedBucketSortValue (sourceRows, sortBy) { + if (!sourceRows?.length) return 0 + if (sortBy === 'stock_qty') return distinctVariantStockQty(sourceRows) + if (sortBy === 'idle_cost_usd') return distinctVariantStockCost(sourceRows) + if (sortBy === 'base_price_usd' || sortBy === 'cost_price_usd') return weightedAverageProductCost(sourceRows, sortBy) + if (shouldSumField(sortBy)) return sumRows(sourceRows, sortBy) + if (shouldAverageField(sortBy)) return weightedAverageOrAverage(sourceRows, sortBy, weightFieldForMetric(sortBy)) + return sortValueForColumnName(sourceRows[0], sortBy) +} + function sortProductGroupedTableRows (tableRows, sortBy, descending) { const source = Array.isArray(tableRows) ? tableRows : [] if (!sortBy || !source.some(row => row?.__group)) { @@ -3437,13 +3483,10 @@ function marginFromRows (sourceRows, salesField, qtyField, unitCostField) { } function normalizeGroupValue (value) { - return String(value || '').trim() + return String(value || '').trim() || '(Boş)' } function shouldSkipGroupValue (field, value) { - const text = String(value || '').trim() - if (field === 'urun_ilk_grubu') return !text || text === '-' - if (field === 'askili_yan') return !text || text === '-' return false } @@ -3709,14 +3752,15 @@ function syncExpansionAfterFilterChange () { } function filterRowsForTable (tableKey, sourceRows, sourceColumns) { - return sourceRows.filter(row => { - return sourceColumns.every(col => { - if (!isColumnFilterable(col.name)) return true - const selected = selectedColumnFilters(tableKey, col.name) - if (!selected.length) return true - return selected.includes(columnFilterValue(row, col)) - }) - }) + const activeFilters = sourceColumns + .filter(col => isColumnFilterable(col.name)) + .map(col => ({ + col, + selected: new Set(selectedColumnFilters(tableKey, col.name)) + })) + .filter(item => item.selected.size > 0) + if (!activeFilters.length) return sourceRows + return sourceRows.filter(row => activeFilters.every(item => item.selected.has(columnFilterValue(row, item.col)))) } function columnFilterValue (row, col) { @@ -4465,7 +4509,6 @@ function setupTopScrollbarSync () { function switchPerformanceTab (tabName) { if (!tabName || tabName === activeTab.value || loading.value) return - backendGroupedLoading.value = true activeTab.value = tabName } @@ -4887,7 +4930,7 @@ onBeforeUnmount(() => { align-items: center; justify-content: center; gap: 12px; - background: rgba(246, 247, 249, 0.82); + background: #f6f7f9; backdrop-filter: blur(2px); pointer-events: all; }