diff --git a/svc/queries/product_performance.go b/svc/queries/product_performance.go index ed77a71..a415168 100644 --- a/svc/queries/product_performance.go +++ b/svc/queries/product_performance.go @@ -1072,6 +1072,9 @@ func RebuildProductPerformanceGroupedSnapshots(ctx context.Context, pg *sql.DB) return 0, err } log.Printf("[ProductPerformanceRefresh] grouped snapshot definitions ready count=%d", len(defs)) + if err := deleteStaleProductPerformanceGroupedSnapshots(ctx, pg, productPerformanceSnapshotKey("grouped", "product_detail")+":%"); err != nil { + return 0, err + } total := 0 for i, def := range defs { started := time.Now() @@ -1107,7 +1110,23 @@ func RebuildProductPerformanceGroupedSnapshots(ctx context.Context, pg *sql.DB) return total, nil } +func deleteStaleProductPerformanceGroupedSnapshots(ctx context.Context, pg *sql.DB, reportKeyLike string) error { + reportKeyLike = strings.TrimSpace(reportKeyLike) + if reportKeyLike == "" { + return nil + } + if _, err := pg.ExecContext(ctx, `DELETE FROM mk_product_performance_grouped_snapshot WHERE report_key LIKE $1`, reportKeyLike); err != nil { + return err + } + if _, err := pg.ExecContext(ctx, `DELETE FROM mk_product_performance_grouped_snapshot_meta WHERE report_key LIKE $1`, reportKeyLike); err != nil { + return err + } + return nil +} + func productPerformanceGroupedSnapshotDefinitions(ctx context.Context, pg *sql.DB) ([]productPerformanceGroupedSnapshotDefinition, error) { + _ = ctx + _ = pg 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"}}, @@ -1118,30 +1137,6 @@ func productPerformanceGroupedSnapshotDefinitions(ctx context.Context, pg *sql.D {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 } @@ -3920,6 +3915,7 @@ func ListProductPerformanceGrouped(ctx context.Context, pg *sql.DB, req ProductP sourceRows = filterProductPerformanceGroupedRows(sourceRows, productPerformanceGroupedEffectiveFilters(req)) out := make([]map[string]any, 0, len(sourceRows)) appendProductPerformanceGroupedRows(&out, sourceRows, levels, 0, 0, []string{"tab:" + req.Mode}, req.ExpandedKeys, req.ExpandThroughLevel) + out = sortProductPerformancePreparedGroupedRows(out, req.SortBy, req.Descending) if len(out) > 0 || !productPerformanceLiveFallbackEnabled() { return out, nil } @@ -4038,6 +4034,9 @@ func loadProductPerformancePreparedGroupedRows(ctx context.Context, pg *sql.DB, } effectiveFilters := productPerformancePreparedGroupedEffectiveFilters(req) hasFilters := len(effectiveFilters) > 0 + if hasFilters { + return nil, false, nil + } hasManualExpansion := len(req.ExpandedKeys) > 0 query := ` SELECT payload @@ -4098,7 +4097,6 @@ SELECT 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 @@ -4988,9 +4986,9 @@ 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 { + if rows, err := productPerformanceGroupedRawSnapshotSourceRows(ctx, pg, mode, limit); err != nil { return nil, err - } else if ok { + } else if rows != nil { return rows, nil } @@ -5036,6 +5034,7 @@ func productPerformanceGroupedRawSnapshotSourceRows(ctx context.Context, pg *sql } if mode == "products" || mode == "product_detail" { rows = mergeProductPerformanceGeneralSnapshotMetrics(ctx, pg, rows) + rows = mergeProductPerformanceSalesSpreadKeys(ctx, pg, rows) } return rows, nil } @@ -5076,6 +5075,88 @@ func mergeProductPerformanceGeneralSnapshotMetrics(ctx context.Context, pg *sql. return rows } +func mergeProductPerformanceSalesSpreadKeys(ctx context.Context, pg *sql.DB, rows []map[string]any) []map[string]any { + if len(rows) == 0 { + return rows + } + const query = ` +WITH Latest AS ( + SELECT COALESCE( + (SELECT MAX(kpi_date) FROM mk_product_performance_kpi_daily), + (SELECT MAX(sales_date) FROM mk_product_performance_sales_daily), + current_date + )::date AS kpi_date +), +Spread AS ( + SELECT + s.product_code, + s.color_code, + s.yaka_kodu, + s.market_key, + COALESCE(jsonb_agg(DISTINCT btrim(s.customer_code)) FILTER ( + WHERE s.sales_date BETWEEN Latest.kpi_date - INTERVAL '89 days' AND Latest.kpi_date + AND COALESCE(s.sales_usd,0) > 0 + AND btrim(COALESCE(s.customer_code,'')) NOT IN ('', '-') + ), '[]'::jsonb) AS customer_keys_90d, + COALESCE(jsonb_agg(DISTINCT btrim(s.customer_code)) FILTER ( + WHERE s.sales_date BETWEEN Latest.kpi_date - INTERVAL '179 days' AND Latest.kpi_date + AND COALESCE(s.sales_usd,0) > 0 + AND btrim(COALESCE(s.customer_code,'')) NOT IN ('', '-') + ), '[]'::jsonb) AS customer_keys_180d, + COALESCE(jsonb_agg(DISTINCT btrim(s.customer_code)) FILTER ( + WHERE s.sales_date BETWEEN Latest.kpi_date - INTERVAL '359 days' AND Latest.kpi_date + AND COALESCE(s.sales_usd,0) > 0 + AND btrim(COALESCE(s.customer_code,'')) NOT IN ('', '-') + ), '[]'::jsonb) AS customer_keys_365d, + COALESCE(jsonb_agg(DISTINCT btrim(s.customer_code)) FILTER ( + WHERE s.sales_date BETWEEN DATE '2022-01-01' AND Latest.kpi_date + AND COALESCE(s.sales_usd,0) > 0 + AND btrim(COALESCE(s.customer_code,'')) NOT IN ('', '-') + ), '[]'::jsonb) AS customer_keys_total + FROM mk_product_performance_sales_daily s + CROSS JOIN Latest + WHERE s.sales_date BETWEEN DATE '2022-01-01' AND Latest.kpi_date + AND upper(translate(btrim(COALESCE(s.urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER') + GROUP BY s.product_code, s.color_code, s.yaka_kodu, s.market_key +) +SELECT jsonb_build_object( + 'product_code', product_code, + 'color_code', color_code, + 'yaka_kodu', yaka_kodu, + 'market_key', market_key, + '__customer_keys_90d', customer_keys_90d, + '__customer_keys_180d', customer_keys_180d, + '__customer_keys_365d', customer_keys_365d, + '__customer_keys_total', customer_keys_total +) +FROM Spread` + spreadRows, err := queryProductPerformanceJSONRows(ctx, pg, query) + if err != nil { + log.Printf("[ProductPerformanceRefresh] product sales spread keys skipped err=%v", err) + return rows + } + byKey := make(map[string]map[string]any, len(spreadRows)) + for _, row := range spreadRows { + key := productPerformanceMapMarketVariantKey(row) + if key != "" { + byKey[key] = row + } + } + for _, row := range rows { + spread := byKey[productPerformanceMapMarketVariantKey(row)] + if spread == nil { + continue + } + for _, suffix := range productPerformancePeriodSuffixes() { + field := "__customer_keys_" + suffix + if value, ok := spread[field]; ok { + row[field] = value + } + } + } + return rows +} + func productPerformanceMapMarketVariantKey(row map[string]any) string { productCode := normalizeProductPerformanceProductCode(stringFromMap(row, "product_code")) if productCode == "" { @@ -5181,23 +5262,21 @@ type productPerformanceGroupedSnapshotAvgState struct { } type productPerformanceGroupedSnapshotNode struct { - Key string - Level int - Field string - Value string - Row map[string]any - Count int - Children map[string]*productPerformanceGroupedSnapshotNode - ChildOrder []string - StockMetricSeen map[string]map[string]bool - IdleSeen map[string]bool - Avg map[string]*productPerformanceGroupedSnapshotAvgState - BucketCounts map[string]int - Image map[string]any - Market90Seen map[string]bool - MarketTotalSeen map[string]bool - Customer90Seen map[string]bool - CustomerTotalSeen map[string]bool + Key string + Level int + Field string + Value string + Row map[string]any + Count int + Children map[string]*productPerformanceGroupedSnapshotNode + ChildOrder []string + StockMetricSeen map[string]map[string]bool + IdleSeen map[string]bool + Avg map[string]*productPerformanceGroupedSnapshotAvgState + BucketCounts map[string]int + Image map[string]any + MarketSeen map[string]map[string]bool + CustomerSeen map[string]map[string]bool } func buildProductPerformanceGroupedSnapshotRows(sourceRows []map[string]any, levels []string, mode string) []map[string]any { @@ -5253,7 +5332,7 @@ func (n *productPerformanceGroupedSnapshotNode) add(row map[string]any) { n.BucketCounts[bucket]++ } for key, value := range row { - if key == "row_key" || key == "key" || isProductPerformanceMarginField(key) { + if key == "row_key" || key == "key" || isProductPerformanceInternalGroupField(key) || isProductPerformanceMarginField(key) { continue } switch { @@ -5324,34 +5403,35 @@ func (n *productPerformanceGroupedSnapshotNode) addDistinctVariantMetric(row map func (n *productPerformanceGroupedSnapshotNode) addDistinctSpread(row map[string]any) { market := displayProductPerformanceMarketName(stringFromMap(row, "market_key")) if market != "" && market != "STOK" { - if floatFromMap(row, "sales_usd_90d") > 0 { - if n.Market90Seen == nil { - n.Market90Seen = map[string]bool{} + for _, suffix := range productPerformancePeriodSuffixes() { + if floatFromMap(row, "sales_usd_"+suffix) > 0 { + if n.MarketSeen == nil { + n.MarketSeen = map[string]map[string]bool{} + } + if n.MarketSeen[suffix] == nil { + n.MarketSeen[suffix] = map[string]bool{} + } + n.MarketSeen[suffix][market] = true } - n.Market90Seen[market] = true - } - if floatFromMap(row, "sales_usd_total") > 0 { - if n.MarketTotalSeen == nil { - n.MarketTotalSeen = map[string]bool{} - } - n.MarketTotalSeen[market] = true } } customer := strings.TrimSpace(stringFromMap(row, "customer_code")) if customer != "" && customer != "-" { - if floatFromMap(row, "sales_usd_90d") > 0 { - if n.Customer90Seen == nil { - n.Customer90Seen = map[string]bool{} + for _, suffix := range productPerformancePeriodSuffixes() { + if floatFromMap(row, "sales_usd_"+suffix) > 0 { + if n.CustomerSeen == nil { + n.CustomerSeen = map[string]map[string]bool{} + } + if n.CustomerSeen[suffix] == nil { + n.CustomerSeen[suffix] = map[string]bool{} + } + n.CustomerSeen[suffix][customer] = true } - n.Customer90Seen[customer] = true - } - if floatFromMap(row, "sales_usd_total") > 0 { - if n.CustomerTotalSeen == nil { - n.CustomerTotalSeen = map[string]bool{} - } - n.CustomerTotalSeen[customer] = true } } + for _, suffix := range productPerformancePeriodSuffixes() { + n.CustomerSeen = productPerformanceAddSeenStrings(n.CustomerSeen, suffix, productPerformanceStringSliceFromMap(row, "__customer_keys_"+suffix)) + } } func appendProductPerformanceGroupedSnapshotNodes(out *[]map[string]any, nodes map[string]*productPerformanceGroupedSnapshotNode, order []string) { @@ -5387,7 +5467,7 @@ func (n *productPerformanceGroupedSnapshotNode) snapshotRow() map[string]any { row[key] = state.Sum / state.Count } } - applyProductPerformanceDistinctSpread(row, n.Market90Seen, n.MarketTotalSeen, n.Customer90Seen, n.CustomerTotalSeen) + applyProductPerformanceDistinctSpread(row, n.MarketSeen, n.CustomerSeen) deriveProductPerformanceGroupMetrics(row, n.Field) if bucket := n.dominantBucket(); bucket != "" { row["performance_bucket"] = bucket @@ -5508,14 +5588,12 @@ func clearProductPerformanceGroupDimensions(row map[string]any, groupField, grou func aggregateProductPerformanceRows(rows []map[string]any, groupField string) map[string]any { out := map[string]any{} - market90Seen := map[string]bool{} - marketTotalSeen := map[string]bool{} - customer90Seen := map[string]bool{} - customerTotalSeen := map[string]bool{} + marketSeen := map[string]map[string]bool{} + customerSeen := map[string]map[string]bool{} for _, row := range rows { - addProductPerformanceDistinctSpread(row, market90Seen, marketTotalSeen, customer90Seen, customerTotalSeen) + addProductPerformanceDistinctSpread(row, marketSeen, customerSeen) for key, value := range row { - if key == "row_key" || key == "key" { + if key == "row_key" || key == "key" || isProductPerformanceInternalGroupField(key) { continue } if isProductPerformanceMarginField(key) { @@ -5541,6 +5619,9 @@ func aggregateProductPerformanceRows(rows []map[string]any, groupField string) m } for _, row := range rows { for key := range row { + if isProductPerformanceInternalGroupField(key) { + continue + } if isProductPerformanceMarginField(key) { continue } @@ -5549,62 +5630,119 @@ func aggregateProductPerformanceRows(rows []map[string]any, groupField string) m } } } - applyProductPerformanceDistinctSpread(out, market90Seen, marketTotalSeen, customer90Seen, customerTotalSeen) + applyProductPerformanceDistinctSpread(out, marketSeen, customerSeen) deriveProductPerformanceGroupMetrics(out, groupField) out["performance_bucket"] = dominantProductPerformanceValue(rows, "performance_bucket") return out } -func addProductPerformanceDistinctSpread(row map[string]any, market90Seen, marketTotalSeen, customer90Seen, customerTotalSeen map[string]bool) { +func addProductPerformanceDistinctSpread(row map[string]any, marketSeen, customerSeen map[string]map[string]bool) { market := displayProductPerformanceMarketName(stringFromMap(row, "market_key")) if market != "" && market != "STOK" { - if floatFromMap(row, "sales_usd_90d") > 0 { - market90Seen[market] = true - } - if floatFromMap(row, "sales_usd_total") > 0 { - marketTotalSeen[market] = true + for _, suffix := range productPerformancePeriodSuffixes() { + if floatFromMap(row, "sales_usd_"+suffix) > 0 { + if marketSeen[suffix] == nil { + marketSeen[suffix] = map[string]bool{} + } + marketSeen[suffix][market] = true + } } } customer := strings.TrimSpace(stringFromMap(row, "customer_code")) if customer != "" && customer != "-" { - if floatFromMap(row, "sales_usd_90d") > 0 { - customer90Seen[customer] = true + for _, suffix := range productPerformancePeriodSuffixes() { + if floatFromMap(row, "sales_usd_"+suffix) > 0 { + if customerSeen[suffix] == nil { + customerSeen[suffix] = map[string]bool{} + } + customerSeen[suffix][customer] = true + } } - if floatFromMap(row, "sales_usd_total") > 0 { - customerTotalSeen[customer] = true + } + for _, suffix := range productPerformancePeriodSuffixes() { + customerSeen = productPerformanceAddSeenStrings(customerSeen, suffix, productPerformanceStringSliceFromMap(row, "__customer_keys_"+suffix)) + } +} + +func applyProductPerformanceDistinctSpread(row map[string]any, marketSeen, customerSeen map[string]map[string]bool) { + for _, suffix := range productPerformancePeriodSuffixes() { + marketField := "market_count_" + suffix + customerField := "customer_count_" + suffix + if seen := marketSeen[suffix]; len(seen) > 0 { + row[marketField] = len(seen) + } + if seen := customerSeen[suffix]; len(seen) > 0 { + row[customerField] = len(seen) + } + if floatFromMap(row, "sales_usd_"+suffix) > 0 && (suffix == "90d" || suffix == "total") { + if intFromMap(row, marketField) == 0 { + row[marketField] = 1 + } + if intFromMap(row, customerField) == 0 { + if suffix == "total" { + row[customerField] = maxInt(1, intFromMap(row, "customer_count_90d")) + } else { + row[customerField] = 1 + } + } } } } -func applyProductPerformanceDistinctSpread(row map[string]any, market90Seen, marketTotalSeen, customer90Seen, customerTotalSeen map[string]bool) { - if len(market90Seen) > 0 { - row["market_count_90d"] = len(market90Seen) +func productPerformanceAddSeenStrings(seen map[string]map[string]bool, suffix string, values []string) map[string]map[string]bool { + if len(values) == 0 { + return seen } - if len(marketTotalSeen) > 0 { - row["market_count_total"] = len(marketTotalSeen) + if seen == nil { + seen = map[string]map[string]bool{} } - if len(customer90Seen) > 0 { - row["customer_count_90d"] = len(customer90Seen) + if seen[suffix] == nil { + seen[suffix] = map[string]bool{} } - if len(customerTotalSeen) > 0 { - row["customer_count_total"] = len(customerTotalSeen) - } - if floatFromMap(row, "sales_usd_90d") > 0 { - if intFromMap(row, "market_count_90d") == 0 { - row["market_count_90d"] = 1 - } - if intFromMap(row, "customer_count_90d") == 0 { - row["customer_count_90d"] = 1 + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" || value == "-" { + continue } + seen[suffix][value] = true } - if floatFromMap(row, "sales_usd_total") > 0 { - if intFromMap(row, "market_count_total") == 0 { - row["market_count_total"] = maxInt(1, intFromMap(row, "market_count_90d")) - } - if intFromMap(row, "customer_count_total") == 0 { - row["customer_count_total"] = maxInt(1, intFromMap(row, "customer_count_90d")) - } + return seen +} + +func productPerformanceStringSliceFromMap(row map[string]any, field string) []string { + value, ok := row[field] + if !ok || value == nil { + return nil } + switch v := value.(type) { + case []string: + return v + case []any: + out := make([]string, 0, len(v)) + for _, item := range v { + text := strings.TrimSpace(fmt.Sprint(item)) + if text != "" { + out = append(out, text) + } + } + return out + case string: + text := strings.TrimSpace(v) + if text == "" { + return nil + } + var parsed []string + if strings.HasPrefix(text, "[") && json.Unmarshal([]byte(text), &parsed) == nil { + return parsed + } + return []string{text} + default: + return nil + } +} + +func isProductPerformanceInternalGroupField(field string) bool { + return strings.HasPrefix(field, "__") } func productPerformanceGroupRecommendation(row map[string]any, groupField string, count int) string { @@ -5711,7 +5849,7 @@ func productPerformanceMapVariantKey(row map[string]any) string { func deriveProductPerformanceGroupMetrics(out map[string]any, groupField string) { normalizeProductPerformanceCostFields(out) - for _, suffix := range []string{"90d", "180d", "365d", "total"} { + for _, suffix := range productPerformancePeriodSuffixes() { sales := floatFromMap(out, "sales_usd_"+suffix) qty := floatFromMap(out, "sales_qty_"+suffix) stockQty := floatFromMap(out, "stock_qty") @@ -5719,6 +5857,19 @@ func deriveProductPerformanceGroupMetrics(out map[string]any, groupField string) if turnoverBase <= 0 { turnoverBase = stockQty } + days := productPerformancePeriodDays(out, suffix) + avgDaily := floatFromMap(out, "avg_daily_sales_"+suffix) + if days > 0 { + avgDaily = qty / days + out["avg_daily_sales_"+suffix] = avgDaily + } + if avgDaily > 0 && turnoverBase > 0 { + out["stock_days_"+suffix] = turnoverBase / avgDaily + } else if qty <= 0 && stockQty > 0 { + out["stock_days_"+suffix] = 9999 + } else if _, ok := out["stock_days_"+suffix]; !ok { + out["stock_days_"+suffix] = 0 + } if turnoverBase > 0 { out["stock_turnover_"+suffix] = qty / turnoverBase } else { @@ -5775,37 +5926,15 @@ func deriveProductPerformanceGroupMetrics(out map[string]any, groupField string) if _, ok := out["net_stock_after_order"]; ok || orderQty > 0 { out["net_stock_after_order"] = floatFromMap(out, "stock_qty") - orderQty } - if _, ok := out["customer_score_90d"]; !ok { - out["customer_score_90d"] = productPerformanceCustomerSalesPeriodScore(rowPeriodMetricMap(out, "90d")) - } - if _, ok := out["customer_score_180d"]; !ok { - out["customer_score_180d"] = productPerformanceCustomerSalesPeriodScore(rowPeriodMetricMap(out, "180d")) - } - if _, ok := out["customer_score_365d"]; !ok { - out["customer_score_365d"] = productPerformanceCustomerSalesPeriodScore(rowPeriodMetricMap(out, "365d")) - } - if _, ok := out["customer_score_total"]; !ok { - out["customer_score_total"] = productPerformanceCustomerSalesPeriodScore(rowPeriodMetricMap(out, "total")) - } - if _, ok := out["performance_score_90d"]; !ok { - out["performance_score_90d"] = productPerformanceSalesPeriodScore(out, "90d") - } - if _, ok := out["performance_score_180d"]; !ok { - out["performance_score_180d"] = productPerformanceSalesPeriodScore(out, "180d") - } - if _, ok := out["performance_score_365d"]; !ok { - out["performance_score_365d"] = productPerformanceSalesPeriodScore(out, "365d") - } - if _, ok := out["performance_score_total"]; !ok { - out["performance_score_total"] = productPerformanceSalesPeriodScore(out, "total") - } - if _, ok := out["performance_score"]; !ok { - if score, exists := productPerformanceOptionalFloat(out, "performance_score_90d"); exists { - out["performance_score"] = score - } else { - out["performance_score"] = productPerformanceGroupScore(out, groupField) - } - } + out["customer_score_90d"] = productPerformanceCustomerSalesPeriodScore(rowPeriodMetricMap(out, "90d")) + 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"] = out["performance_score_90d"] if floatFromMap(out, "order_qty") > 0 || floatFromMap(out, "order_usd") > 0 { score := productPerformanceOrderGroupScore(out) out["performance_score_90d"] = score @@ -5832,11 +5961,56 @@ func normalizeProductPerformanceCostFields(row map[string]any) { } } normalize("cost_price_usd", "base_price_usd") - for _, suffix := range []string{"90d", "180d", "365d", "total"} { + for _, suffix := range productPerformancePeriodSuffixes() { normalize("cost_price_usd_"+suffix, "base_price_usd_"+suffix) } } +func productPerformancePeriodSuffixes() []string { + return []string{"90d", "180d", "365d", "total"} +} + +func productPerformancePeriodDays(row map[string]any, suffix string) float64 { + switch suffix { + case "90d": + return 90 + case "180d": + return 180 + case "365d": + return 365 + case "total": + start := parseProductPerformanceDate(stringFromMap(row, "period_start")) + if start.IsZero() { + start = time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC) + } + end := parseProductPerformanceDate(stringFromMap(row, "period_end")) + if end.IsZero() { + end = parseProductPerformanceDate(stringFromMap(row, "kpi_date")) + } + if end.IsZero() || end.Before(start) { + return 0 + } + return end.Sub(start).Hours()/24 + 1 + default: + return 0 + } +} + +func parseProductPerformanceDate(value string) time.Time { + value = strings.TrimSpace(value) + if value == "" { + return time.Time{} + } + if len(value) >= len("2006-01-02") { + value = value[:len("2006-01-02")] + } + t, err := time.Parse("2006-01-02", value) + if err != nil { + return time.Time{} + } + return t +} + func productPerformanceOptionalFloat(row map[string]any, field string) (float64, bool) { value, ok := row[field] if !ok { @@ -6172,7 +6346,16 @@ func mapGroupValue(row map[string]any, field string) string { } func shouldSumProductPerformanceField(field string) bool { - return strings.HasSuffix(field, "_qty") || + return strings.HasPrefix(field, "sales_qty_") || + strings.HasPrefix(field, "sales_usd_") || + strings.HasPrefix(field, "avg_daily_sales_") || + strings.HasPrefix(field, "gross_profit_") || + strings.HasPrefix(field, "invoice_count_") || + strings.HasPrefix(field, "market_count_") || + strings.HasPrefix(field, "customer_count_") || + strings.HasPrefix(field, "product_group_count_") || + strings.HasPrefix(field, "product_count_") || + strings.HasSuffix(field, "_qty") || strings.HasSuffix(field, "_usd") || strings.HasSuffix(field, "_count") || strings.HasSuffix(field, "_value_usd") || @@ -6189,6 +6372,9 @@ func shouldSumProductPerformanceField(field string) bool { } func shouldAverageProductPerformanceField(field string) bool { + if strings.HasPrefix(field, "avg_daily_sales_") { + return false + } return strings.HasPrefix(field, "avg_") || strings.HasPrefix(field, "unit_") || strings.HasPrefix(field, "base_price") || diff --git a/ui/src/pages/ProductPerformanceProfitability.vue b/ui/src/pages/ProductPerformanceProfitability.vue index 9169e8f..2adb396 100644 --- a/ui/src/pages/ProductPerformanceProfitability.vue +++ b/ui/src/pages/ProductPerformanceProfitability.vue @@ -35,6 +35,15 @@ :disable="pageBusy" class="period-selector bg-white q-px-xs q-py-none" /> + -
- -
-
@@ -113,9 +108,14 @@ flat bordered row-key="row_key" - class="performance-table sticky-dim-table sticky-dim-7 bg-white" + :class="[ + 'performance-table', + 'sticky-dim-table', + detailColumnsHidden ? 'sticky-dim-4' : 'sticky-dim-7', + 'bg-white' + ]" :rows="filteredGeneralRows" - :columns="generalColumns" + :columns="visibleGeneralColumns" :loading="activeTableLoading" v-model:pagination="tablePagination.general" virtual-scroll @@ -560,6 +560,7 @@ 'performance-table', 'product-breakdown-table', 'bg-white', + { 'compact-detail-columns': detailColumnsHidden }, activeTab === 'product_detail' ? 'product-detail-table' : 'product-summary-table' ]" :rows="displayProductKpiTableRows" @@ -1603,6 +1604,7 @@ const loadingNow = ref(0) const loadingStage = ref('') const generalRowsLoaded = ref(false) const detailLevelMenuOpen = ref(false) +const detailColumnsHidden = ref(false) const topScrollbarRef = ref(null) const topScrollbarInnerRef = ref(null) let backendGroupedTimer = null @@ -1675,7 +1677,6 @@ const productPerformanceExcelExportFilterFields = new Set([ ]) const performanceTabs = [ { name: 'products', icon: 'dashboard', label: 'Genel Özet KPI' }, - { name: 'product_detail', icon: 'category', label: 'Detay KPI' }, { name: 'sales_color_yaka_market_customer', icon: 'palette', label: 'Renk/Yaka > Piyasa > Müşteri' }, { name: 'idle', icon: 'warning', label: 'Atıl Stok / Maliyet' }, { name: 'sales_product_country_segment_market_customer', icon: 'account_tree', label: 'Ürün > Ülke > Segment > Piyasa > Müşteri' }, @@ -2024,7 +2025,32 @@ const productColumns = computed(() => orderedMetricColumns(columns.filter(visibl const productDetailColumns = computed(() => orderedMetricColumns(columns .filter(col => !['urun_ilk_grubu', 'askili_yan', 'kategori'].includes(col.name)) .filter(visiblePeriodColumn))) -const activeProductColumns = computed(() => activeTab.value === 'product_detail' ? productDetailColumns.value : productColumns.value) +const detailColumnNames = new Set([ + 'item_description', + 'kategori', + 'askili_yan', + 'urun_ilk_grubu', + 'urun_ana_grubu', + 'urun_alt_grubu', + 'period_start', + 'period_end', + 'country', + 'customer_segment', + 'customer_code', + 'customer_name', + 'first_sale_date', + 'last_sale_date', + 'last_ref_number' +]) + +function applyDetailColumnVisibility (sourceColumns) { + if (!detailColumnsHidden.value) return sourceColumns + return sourceColumns.filter(col => !detailColumnNames.has(col.name)) +} + +const visibleProductColumns = computed(() => applyDetailColumnVisibility(productColumns.value)) +const visibleProductDetailColumns = computed(() => applyDetailColumnVisibility(productDetailColumns.value)) +const activeProductColumns = computed(() => activeTab.value === 'product_detail' ? visibleProductDetailColumns.value : visibleProductColumns.value) const generalColumns = [ { name: 'image', label: 'Foto', field: 'image', align: 'center' }, @@ -2066,6 +2092,8 @@ const generalColumns = [ { name: 'recommendation', label: 'Öneri', field: 'recommendation', align: 'left' } ] +const visibleGeneralColumns = computed(() => applyDetailColumnVisibility(generalColumns)) + const orderAnalysisColumns = [ { name: 'image', label: 'Foto', field: 'image', align: 'center' }, { name: 'product_code', label: 'Ürün', field: 'product_code', align: 'left', sortable: true }, @@ -2513,7 +2541,6 @@ function ensureColorYakaColumn (targetColumns) { ].forEach(target => ensureColumns(target, customerPeriodScoreColumns)) ;[ - generalColumns, orderAnalysisColumns, orderGroupColumns, orderProductCustomerColumns, @@ -3052,7 +3079,7 @@ function filterSourceRowsForTab (tabKey, fallbackRows) { } function backendGroupedFilterOptionParams (tabKey) { - const columns = columnsForTableKey(tabKey) + const columns = filterColumnsForTableKey(tabKey) const fields = columns .map(col => col?.name || '') .filter(name => backendGroupedFilterFields.has(name)) @@ -3132,7 +3159,7 @@ function backendFilterOptionLabel (name, value) { function backendGroupedFilterState (tabKey) { const out = {} - const columns = columnsForTableKey(tabKey) + const columns = filterColumnsForTableKey(tabKey) for (const col of columns) { const name = col?.name || '' if (!isColumnFilterable(name)) continue @@ -3152,7 +3179,7 @@ function shouldUseBackendGroupedRows (tabKey) { function tableFilterSource (tableKey) { if (backendGroupedSupportedTab(tableKey)) { - return { rows: backendGroupedRows.value[tableKey] || [], columns: columnsForTableKey(tableKey) } + return { rows: backendGroupedRows.value[tableKey] || [], columns: filterColumnsForTableKey(tableKey) } } if (tableKey === 'general') return { rows: generalRows.value, columns: generalColumns } if (tableKey === 'markets') return { rows: marketRows.value, columns: marketColumns } @@ -3162,6 +3189,20 @@ function tableFilterSource (tableKey) { } function columnsForTableKey (tabKey) { + if (tabKey === 'products') return visibleProductColumns.value + if (tabKey === 'product_detail') return visibleProductDetailColumns.value + if (tabKey === 'general') return visibleGeneralColumns.value + if (tabKey === 'order_product_customers') return orderProductCustomerColumns + if (tabKey === 'order_market_details') return orderMarketDetailColumns + if (tabKey === 'idle') return idleColumns + if (tabKey === 'markets') return marketColumns + if (tabKey === 'countries') return countryColumns + if (tabKey === 'customers') return customerColumns + if (salesBreakdownTabKeys.includes(tabKey)) return visibleSalesBreakdownColumns.value + return productColumns.value +} + +function filterColumnsForTableKey (tabKey) { if (tabKey === 'products') return productColumns.value if (tabKey === 'product_detail') return productDetailColumns.value if (tabKey === 'general') return generalColumns @@ -3466,10 +3507,12 @@ function aggregateGroupFields (sourceRows, groupField = '') { } function shouldSumField (field) { - return /(_qty|_usd|_count|_value_usd|line_count|invoice_count|order_count|product_count|market_count|customer_count|overdue_qty|stock_qty|net_stock_after_order|idle_cost_usd)$/i.test(field) + return /^(sales_qty_|sales_usd_|avg_daily_sales_|gross_profit_|invoice_count_|market_count_|customer_count_|product_group_count_|product_count_)/i.test(field) || + /(_qty|_usd|_count|_value_usd|line_count|invoice_count|order_count|product_count|market_count|customer_count|overdue_qty|stock_qty|net_stock_after_order|idle_cost_usd)$/i.test(field) } function shouldAverageField (field) { + if (/^avg_daily_sales_/i.test(field)) return false return /^(avg_|unit_|base_price|cost_price|gross_margin|expected_margin|sales_index|performance_score|customer_score|stock_days|stock_turnover)/i.test(field) || /(_price_usd|_margin|_index|_days)$/i.test(field) } @@ -3491,6 +3534,12 @@ function applyDerivedGroupMetrics (out, sourceRows, groupField = '') { const stockQty = Number(out.stock_qty || 0) const avgStock = Number(out[`avg_stock_${suffix}`] || 0) const turnoverBase = avgStock > 0 ? avgStock : stockQty + const days = productPerformancePeriodDays(out, suffix) + const avgDaily = days > 0 ? qty / days : Number(out[`avg_daily_sales_${suffix}`] || 0) + if (days > 0) out[`avg_daily_sales_${suffix}`] = avgDaily + if (avgDaily > 0 && turnoverBase > 0) out[`stock_days_${suffix}`] = turnoverBase / avgDaily + else if (qty <= 0 && stockQty > 0) out[`stock_days_${suffix}`] = 9999 + else if (!Object.prototype.hasOwnProperty.call(out, `stock_days_${suffix}`)) out[`stock_days_${suffix}`] = 0 out[`stock_turnover_${suffix}`] = turnoverBase > 0 ? qty / turnoverBase : 0 if (qty > 0) out[`avg_price_usd_${suffix}`] = sales / qty const { costPrice, basePrice } = periodCostPair(out, suffix) @@ -3530,19 +3579,33 @@ function applyDerivedGroupMetrics (out, sourceRows, groupField = '') { if (sourceRows.some(row => row.performance_bucket)) { out.performance_bucket = dominantValue(sourceRows, 'performance_bucket') } - if (!Object.prototype.hasOwnProperty.call(out, 'customer_score_90d')) out.customer_score_90d = customerSalesPeriodScore(periodMetricSource(out, '90d')) - if (!Object.prototype.hasOwnProperty.call(out, 'customer_score_180d')) out.customer_score_180d = customerSalesPeriodScore(periodMetricSource(out, '180d')) - if (!Object.prototype.hasOwnProperty.call(out, 'customer_score_365d')) out.customer_score_365d = customerSalesPeriodScore(periodMetricSource(out, '365d')) - if (!Object.prototype.hasOwnProperty.call(out, 'customer_score_total')) out.customer_score_total = customerSalesPeriodScore(periodMetricSource(out, 'total')) - if (!Object.prototype.hasOwnProperty.call(out, 'performance_score_90d')) out.performance_score_90d = productSalesPeriodScore(productPeriodMetricSource(out, '90d')) - if (!Object.prototype.hasOwnProperty.call(out, 'performance_score_180d')) out.performance_score_180d = productSalesPeriodScore(productPeriodMetricSource(out, '180d')) - if (!Object.prototype.hasOwnProperty.call(out, 'performance_score_365d')) out.performance_score_365d = productSalesPeriodScore(productPeriodMetricSource(out, '365d')) - if (!Object.prototype.hasOwnProperty.call(out, 'performance_score_total')) out.performance_score_total = productSalesPeriodScore(productPeriodMetricSource(out, 'total')) - if (!Object.prototype.hasOwnProperty.call(out, 'performance_score')) { - out.performance_score = Object.prototype.hasOwnProperty.call(out, 'performance_score_90d') - ? Number(out.performance_score_90d || 0) - : groupPerformanceScore(out, groupField) - } + out.customer_score_90d = customerSalesPeriodScore(periodMetricSource(out, '90d')) + out.customer_score_180d = customerSalesPeriodScore(periodMetricSource(out, '180d')) + out.customer_score_365d = customerSalesPeriodScore(periodMetricSource(out, '365d')) + out.customer_score_total = customerSalesPeriodScore(periodMetricSource(out, 'total')) + out.performance_score_90d = productSalesPeriodScore(productPeriodMetricSource(out, '90d')) + out.performance_score_180d = productSalesPeriodScore(productPeriodMetricSource(out, '180d')) + out.performance_score_365d = productSalesPeriodScore(productPeriodMetricSource(out, '365d')) + out.performance_score_total = productSalesPeriodScore(productPeriodMetricSource(out, 'total')) + out.performance_score = Number(out.performance_score_90d || 0) +} + +function productPerformancePeriodDays (row, suffix) { + if (suffix === '90d') return 90 + if (suffix === '180d') return 180 + if (suffix === '365d') return 365 + if (suffix !== 'total') return 0 + const start = parseProductPerformanceDate(row?.period_start) || new Date(Date.UTC(2022, 0, 1)) + const end = parseProductPerformanceDate(row?.period_end) || parseProductPerformanceDate(row?.kpi_date) + if (!start || !end || end < start) return 0 + return Math.floor((end.getTime() - start.getTime()) / 86400000) + 1 +} + +function parseProductPerformanceDate (value) { + const text = String(value || '').trim().slice(0, 10) + if (!/^\d{4}-\d{2}-\d{2}$/.test(text)) return null + const [year, month, day] = text.split('-').map(Number) + return new Date(Date.UTC(year, month - 1, day)) } function groupPerformanceScore (row, groupField = '') { @@ -5204,7 +5267,7 @@ function productPerformanceExcelExportTableKey () { function buildProductPerformanceExcelExportFilters () { const tableKey = productPerformanceExcelExportTableKey() const out = {} - for (const col of columnsForTableKey(tableKey) || []) { + for (const col of filterColumnsForTableKey(tableKey) || []) { const name = String(col?.name || '').trim() if (!productPerformanceExcelExportFilterFields.has(name)) continue const selected = selectedColumnFilters(tableKey, name) @@ -5986,95 +6049,95 @@ onBeforeUnmount(() => { table-layout: fixed; } -.product-breakdown-table:not(.product-detail-table) :deep(.q-table th:nth-child(-n+10)), -.product-breakdown-table:not(.product-detail-table) :deep(.q-table td:nth-child(-n+10)) { +.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table th:nth-child(-n+10)), +.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table td:nth-child(-n+10)) { position: sticky; z-index: 2; } -.product-breakdown-table:not(.product-detail-table) :deep(.q-table th:nth-child(-n+10)) { +.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table th:nth-child(-n+10)) { z-index: 30; background: #f8fbff; } -.product-breakdown-table:not(.product-detail-table) :deep(.q-table tbody tr:not(.group-row) td:nth-child(-n+10)) { +.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table tbody tr:not(.group-row) td:nth-child(-n+10)) { background: #fff; } -.product-breakdown-table:not(.product-detail-table) :deep(.q-table th:nth-child(1)), -.product-breakdown-table:not(.product-detail-table) :deep(.q-table td:nth-child(1)) { +.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table th:nth-child(1)), +.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table td:nth-child(1)) { left: 0; width: 190px; min-width: 190px; max-width: 190px; } -.product-breakdown-table:not(.product-detail-table) :deep(.q-table th:nth-child(2)), -.product-breakdown-table:not(.product-detail-table) :deep(.q-table td:nth-child(2)) { +.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table th:nth-child(2)), +.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table td:nth-child(2)) { left: 190px; width: 140px; min-width: 140px; max-width: 140px; } -.product-breakdown-table:not(.product-detail-table) :deep(.q-table th:nth-child(3)), -.product-breakdown-table:not(.product-detail-table) :deep(.q-table td:nth-child(3)) { +.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table th:nth-child(3)), +.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table td:nth-child(3)) { left: 330px; width: 110px; min-width: 110px; max-width: 110px; } -.product-breakdown-table:not(.product-detail-table) :deep(.q-table th:nth-child(4)), -.product-breakdown-table:not(.product-detail-table) :deep(.q-table td:nth-child(4)) { +.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table th:nth-child(4)), +.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table td:nth-child(4)) { left: 440px; width: 150px; min-width: 150px; max-width: 150px; } -.product-breakdown-table:not(.product-detail-table) :deep(.q-table th:nth-child(5)), -.product-breakdown-table:not(.product-detail-table) :deep(.q-table td:nth-child(5)) { +.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table th:nth-child(5)), +.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table td:nth-child(5)) { left: 590px; width: 170px; min-width: 170px; max-width: 170px; } -.product-breakdown-table:not(.product-detail-table) :deep(.q-table th:nth-child(6)), -.product-breakdown-table:not(.product-detail-table) :deep(.q-table td:nth-child(6)) { +.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table th:nth-child(6)), +.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table td:nth-child(6)) { left: 760px; width: 150px; min-width: 150px; max-width: 150px; } -.product-breakdown-table:not(.product-detail-table) :deep(.q-table th:nth-child(7)), -.product-breakdown-table:not(.product-detail-table) :deep(.q-table td:nth-child(7)) { +.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table th:nth-child(7)), +.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table td:nth-child(7)) { left: 910px; width: 130px; min-width: 130px; max-width: 130px; } -.product-breakdown-table:not(.product-detail-table) :deep(.q-table th:nth-child(8)), -.product-breakdown-table:not(.product-detail-table) :deep(.q-table td:nth-child(8)) { +.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table th:nth-child(8)), +.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table td:nth-child(8)) { left: 1040px; width: 90px; min-width: 90px; max-width: 90px; } -.product-breakdown-table:not(.product-detail-table) :deep(.q-table th:nth-child(9)), -.product-breakdown-table:not(.product-detail-table) :deep(.q-table td:nth-child(9)) { +.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table th:nth-child(9)), +.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table td:nth-child(9)) { left: 1130px; width: 80px; min-width: 80px; max-width: 80px; } -.product-breakdown-table:not(.product-detail-table) :deep(.q-table th:nth-child(10)), -.product-breakdown-table:not(.product-detail-table) :deep(.q-table td:nth-child(10)) { +.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table th:nth-child(10)), +.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table td:nth-child(10)) { left: 1210px; width: 130px; min-width: 130px; @@ -6082,6 +6145,58 @@ onBeforeUnmount(() => { box-shadow: 8px 0 10px -10px rgba(17, 24, 39, 0.45); } +.product-breakdown-table.compact-detail-columns :deep(.q-table) { + min-width: 2450px; +} + +.product-breakdown-table.compact-detail-columns :deep(.q-table th:nth-child(-n+4)), +.product-breakdown-table.compact-detail-columns :deep(.q-table td:nth-child(-n+4)) { + position: sticky; + z-index: 2; +} + +.product-breakdown-table.compact-detail-columns :deep(.q-table th:nth-child(-n+4)) { + z-index: 30; + background: #f8fbff; +} + +.product-breakdown-table.compact-detail-columns :deep(.q-table tbody tr:not(.group-row) td:nth-child(-n+4)) { + background: #fff; +} + +.product-breakdown-table.compact-detail-columns :deep(.q-table th:nth-child(1)), +.product-breakdown-table.compact-detail-columns :deep(.q-table td:nth-child(1)) { + left: 0; + width: 86px; + min-width: 86px; + max-width: 86px; +} + +.product-breakdown-table.compact-detail-columns :deep(.q-table th:nth-child(2)), +.product-breakdown-table.compact-detail-columns :deep(.q-table td:nth-child(2)) { + left: 86px; + width: 150px; + min-width: 150px; + max-width: 150px; +} + +.product-breakdown-table.compact-detail-columns :deep(.q-table th:nth-child(3)), +.product-breakdown-table.compact-detail-columns :deep(.q-table td:nth-child(3)) { + left: 236px; + width: 112px; + min-width: 112px; + max-width: 112px; +} + +.product-breakdown-table.compact-detail-columns :deep(.q-table th:nth-child(4)), +.product-breakdown-table.compact-detail-columns :deep(.q-table td:nth-child(4)) { + left: 348px; + width: 132px; + min-width: 132px; + max-width: 132px; + box-shadow: 8px 0 10px -10px rgba(17, 24, 39, 0.45); +} + .product-breakdown-table.product-detail-table :deep(.q-table) { min-width: 2620px; } @@ -6198,10 +6313,14 @@ onBeforeUnmount(() => { font-variant-numeric: tabular-nums; } -.product-breakdown-table:not(.product-detail-table) :deep(.q-table th:nth-child(n+11):not(.text-right)), -.product-breakdown-table:not(.product-detail-table) :deep(.q-table td:nth-child(n+11):not(.text-right)), +.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table th:nth-child(n+11):not(.text-right)), +.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table td:nth-child(n+11):not(.text-right)), +.product-breakdown-table.compact-detail-columns :deep(.q-table th:nth-child(n+5):not(.text-right)), +.product-breakdown-table.compact-detail-columns :deep(.q-table td:nth-child(n+5):not(.text-right)), .sticky-dim-table.sticky-dim-3 :deep(.q-table th:nth-child(n+4):not(.text-right)), .sticky-dim-table.sticky-dim-3 :deep(.q-table td:nth-child(n+4):not(.text-right)), +.sticky-dim-table.sticky-dim-4 :deep(.q-table th:nth-child(n+5):not(.text-right)), +.sticky-dim-table.sticky-dim-4 :deep(.q-table td:nth-child(n+5):not(.text-right)), .sticky-dim-table.sticky-dim-5 :deep(.q-table th:nth-child(n+6):not(.text-right)), .sticky-dim-table.sticky-dim-5 :deep(.q-table td:nth-child(n+6):not(.text-right)), .sticky-dim-table.sticky-dim-6 :deep(.q-table th:nth-child(n+7):not(.text-right)), @@ -6281,6 +6400,8 @@ onBeforeUnmount(() => { .sticky-dim-table.sticky-dim-3 :deep(.q-table th:nth-child(-n+3)), .sticky-dim-table.sticky-dim-3 :deep(.q-table td:nth-child(-n+3)), +.sticky-dim-table.sticky-dim-4 :deep(.q-table th:nth-child(-n+4)), +.sticky-dim-table.sticky-dim-4 :deep(.q-table td:nth-child(-n+4)), .sticky-dim-table.sticky-dim-5 :deep(.q-table th:nth-child(-n+5)), .sticky-dim-table.sticky-dim-5 :deep(.q-table td:nth-child(-n+5)), .sticky-dim-table.sticky-dim-6 :deep(.q-table th:nth-child(-n+6)), @@ -6300,6 +6421,7 @@ onBeforeUnmount(() => { } .sticky-dim-table.sticky-dim-3 :deep(.q-table th:nth-child(-n+3)), +.sticky-dim-table.sticky-dim-4 :deep(.q-table th:nth-child(-n+4)), .sticky-dim-table.sticky-dim-5 :deep(.q-table th:nth-child(-n+5)), .sticky-dim-table.sticky-dim-6 :deep(.q-table th:nth-child(-n+6)), .sticky-dim-table.sticky-dim-7 :deep(.q-table th:nth-child(-n+7)), @@ -6317,6 +6439,8 @@ onBeforeUnmount(() => { .sticky-dim-table.sticky-dim-3 :deep(.q-table th:nth-child(3)), .sticky-dim-table.sticky-dim-3 :deep(.q-table td:nth-child(3)), +.sticky-dim-table.sticky-dim-4 :deep(.q-table th:nth-child(4)), +.sticky-dim-table.sticky-dim-4 :deep(.q-table td:nth-child(4)), .sticky-dim-table.sticky-dim-5 :deep(.q-table th:nth-child(5)), .sticky-dim-table.sticky-dim-5 :deep(.q-table td:nth-child(5)), .sticky-dim-table.sticky-dim-6 :deep(.q-table th:nth-child(6)),