diff --git a/svc/models/product_performance.go b/svc/models/product_performance.go index 0ced5c4..fab9f0e 100644 --- a/svc/models/product_performance.go +++ b/svc/models/product_performance.go @@ -4,6 +4,7 @@ type ProductPerformanceRow struct { KpiDate string `json:"kpi_date"` ProductCode string `json:"product_code"` ColorCode string `json:"color_code"` + ColorDescription string `json:"color_description"` YakaKodu string `json:"yaka_kodu"` ItemDescription string `json:"item_description"` Kategori string `json:"kategori"` @@ -86,6 +87,7 @@ type ProductPerformanceGeneralRow struct { PeriodEnd string `json:"period_end"` ProductCode string `json:"product_code"` ColorCode string `json:"color_code"` + ColorDescription string `json:"color_description"` YakaKodu string `json:"yaka_kodu"` ItemDescription string `json:"item_description"` Kategori string `json:"kategori"` @@ -123,6 +125,7 @@ type ProductPerformanceGeneralRow struct { type ProductPerformanceOrderAnalysisRow struct { ProductCode string `json:"product_code"` ColorCode string `json:"color_code"` + ColorDescription string `json:"color_description"` YakaKodu string `json:"yaka_kodu"` ItemDescription string `json:"item_description"` Kategori string `json:"kategori"` @@ -187,6 +190,7 @@ type ProductPerformanceOrderGroupRow struct { type ProductPerformanceOrderProductCustomerRow struct { ProductCode string `json:"product_code"` ColorCode string `json:"color_code"` + ColorDescription string `json:"color_description"` YakaKodu string `json:"yaka_kodu"` ItemDescription string `json:"item_description"` Kategori string `json:"kategori"` @@ -224,6 +228,7 @@ type ProductPerformanceOrderMarketDetailRow struct { DueDate string `json:"due_date"` ProductCode string `json:"product_code"` ColorCode string `json:"color_code"` + ColorDescription string `json:"color_description"` YakaKodu string `json:"yaka_kodu"` ItemDescription string `json:"item_description"` Kategori string `json:"kategori"` @@ -306,6 +311,7 @@ type ProductPerformanceSalesBreakdownRow struct { Breakdown string `json:"breakdown"` ProductCode string `json:"product_code"` ColorCode string `json:"color_code"` + ColorDescription string `json:"color_description"` YakaKodu string `json:"yaka_kodu"` ItemDescription string `json:"item_description"` Kategori string `json:"kategori"` diff --git a/svc/queries/product_performance.go b/svc/queries/product_performance.go index 3c82b9e..a627a10 100644 --- a/svc/queries/product_performance.go +++ b/svc/queries/product_performance.go @@ -369,6 +369,9 @@ SET urun_ilk_grubu = '', updated_at = now() WHERE btrim(urun_ilk_grubu) = '-' OR upper(translate(btrim(urun_ilk_grubu), 'İŞĞÜÖÇ', 'ISGUOC')) IN ('YETISKIN', 'YETISKIN/GARSON', 'GARSON')`, ` +DELETE FROM mk_product_performance_sales_daily +WHERE upper(btrim(COALESCE(urun_ilk_grubu,''))) IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'DIGER')`, + ` UPDATE mk_product_performance_sales_daily SET askili_yan = '', updated_at = now() WHERE btrim(askili_yan) = '-'`, @@ -434,6 +437,18 @@ CREATE TABLE IF NOT EXISTS mk_product_performance_price_dim ( `ALTER TABLE mk_product_performance_price_dim ADD COLUMN IF NOT EXISTS urun_ana_grubu TEXT NOT NULL DEFAULT ''`, `ALTER TABLE mk_product_performance_price_dim ADD COLUMN IF NOT EXISTS urun_alt_grubu TEXT NOT NULL DEFAULT ''`, ` +UPDATE mk_product_performance_price_dim +SET + cost_price_usd = GREATEST(cost_price_usd, base_price_usd), + base_price_usd = LEAST(cost_price_usd, base_price_usd), + updated_at = now() +WHERE cost_price_usd > 0 + AND base_price_usd > 0 + AND cost_price_usd < base_price_usd`, + ` +DELETE FROM mk_product_performance_price_dim +WHERE upper(btrim(COALESCE(urun_ilk_grubu,''))) IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'DIGER')`, + ` CREATE TABLE IF NOT EXISTS mk_product_performance_kpi_daily ( kpi_date DATE NOT NULL, product_code TEXT NOT NULL, @@ -501,6 +516,26 @@ SET urun_ilk_grubu = '', updated_at = now() WHERE btrim(urun_ilk_grubu) = '-' OR upper(translate(btrim(urun_ilk_grubu), 'İŞĞÜÖÇ', 'ISGUOC')) IN ('YETISKIN', 'YETISKIN/GARSON', 'GARSON')`, ` +DELETE FROM mk_product_performance_kpi_daily +WHERE upper(btrim(COALESCE(urun_ilk_grubu,''))) IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'DIGER')`, + ` +UPDATE mk_product_performance_kpi_daily +SET + cost_price_usd = GREATEST(cost_price_usd, base_price_usd), + base_price_usd = LEAST(cost_price_usd, base_price_usd), + gross_profit_usd_90d = COALESCE(sales_usd_90d,0) - (COALESCE(sales_qty_90d,0) * GREATEST(cost_price_usd, base_price_usd)), + gross_profit_usd_180d = COALESCE(sales_usd_180d,0) - (COALESCE(sales_qty_180d,0) * GREATEST(cost_price_usd, base_price_usd)), + gross_margin_90d = CASE WHEN COALESCE(sales_usd_90d,0) = 0 THEN 0 ELSE (COALESCE(sales_usd_90d,0) - (COALESCE(sales_qty_90d,0) * GREATEST(cost_price_usd, base_price_usd))) / NULLIF(sales_usd_90d,0) END, + gross_margin_180d = CASE WHEN COALESCE(sales_usd_180d,0) = 0 THEN 0 ELSE (COALESCE(sales_usd_180d,0) - (COALESCE(sales_qty_180d,0) * GREATEST(cost_price_usd, base_price_usd))) / NULLIF(sales_usd_180d,0) END, + unit_profit_cost_90d = CASE WHEN COALESCE(sales_qty_90d,0) = 0 THEN 0 ELSE (COALESCE(sales_usd_90d,0) / NULLIF(sales_qty_90d,0)) - GREATEST(cost_price_usd, base_price_usd) END, + unit_profit_cost_180d = CASE WHEN COALESCE(sales_qty_180d,0) = 0 THEN 0 ELSE (COALESCE(sales_usd_180d,0) / NULLIF(sales_qty_180d,0)) - GREATEST(cost_price_usd, base_price_usd) END, + unit_profit_base_90d = CASE WHEN COALESCE(sales_qty_90d,0) = 0 THEN 0 ELSE (COALESCE(sales_usd_90d,0) / NULLIF(sales_qty_90d,0)) - LEAST(cost_price_usd, base_price_usd) END, + unit_profit_base_180d = CASE WHEN COALESCE(sales_qty_180d,0) = 0 THEN 0 ELSE (COALESCE(sales_usd_180d,0) / NULLIF(sales_qty_180d,0)) - LEAST(cost_price_usd, base_price_usd) END, + updated_at = now() +WHERE cost_price_usd > 0 + AND base_price_usd > 0 + AND cost_price_usd < base_price_usd`, + ` UPDATE mk_product_performance_kpi_daily SET askili_yan = '', updated_at = now() WHERE btrim(askili_yan) = '-'`, @@ -719,6 +754,67 @@ func normalizeProductPerformanceVariantCodes(productCode, colorCode, yakaKodu *s } } +func productPerformanceColorDescriptions(ctx context.Context, colorCodes []string) map[string]string { + out := map[string]string{} + if db.MssqlDB == nil || len(colorCodes) == 0 { + return out + } + seen := map[string]bool{} + codes := make([]string, 0, len(colorCodes)) + for _, raw := range colorCodes { + code := normalizeProductPerformanceCode(raw) + if code == "" || seen[code] { + continue + } + seen[code] = true + codes = append(codes, code) + } + const batchSize = 500 + for start := 0; start < len(codes); start += batchSize { + end := start + batchSize + if end > len(codes) { + end = len(codes) + } + batch := codes[start:end] + placeholders := make([]string, len(batch)) + args := make([]any, len(batch)) + for i, code := range batch { + placeholders[i] = fmt.Sprintf("@p%d", i+1) + args[i] = code + } + query := fmt.Sprintf(` +SELECT + UPPER(LTRIM(RTRIM(ColorCode))) AS color_code, + MAX(LTRIM(RTRIM(ISNULL(ColorDescription, '')))) AS color_description +FROM cdColorDesc WITH(NOLOCK) +WHERE LangCode = 'TR' + AND UPPER(LTRIM(RTRIM(ColorCode))) IN (%s) +GROUP BY UPPER(LTRIM(RTRIM(ColorCode))) +`, strings.Join(placeholders, ",")) + rows, err := db.MssqlDB.QueryContext(ctx, query, args...) + if err != nil { + log.Printf("[ProductPerformance] color descriptions failed: %v", err) + return out + } + for rows.Next() { + var code, desc string + if err := rows.Scan(&code, &desc); err != nil { + rows.Close() + log.Printf("[ProductPerformance] color description scan failed: %v", err) + return out + } + out[normalizeProductPerformanceCode(code)] = strings.TrimSpace(desc) + } + if err := rows.Err(); err != nil { + rows.Close() + log.Printf("[ProductPerformance] color description rows failed: %v", err) + return out + } + rows.Close() + } + return out +} + func refreshProductPerformanceSales(ctx context.Context, tx *sql.Tx, startDate, endDate time.Time, productPrefix string) (int, error) { log.Printf("[ProductPerformanceRefresh] sales mssql query start start=%s end=%s prefix=%s", startDate.Format("2006-01-02"), endDate.Format("2006-01-02"), productPrefix) rows, err := db.MssqlDB.QueryContext(ctx, productPerformanceSalesSQL(), startDate, endDate, productPrefix) @@ -984,6 +1080,7 @@ func refreshProductPerformancePrices(ctx context.Context, tx *sql.Tx, productPre code = normalizeProductPerformanceProductCode(code) askiliYan = cleanProductPerformanceOptionalAttr(askiliYan) urunIlkGrubu = cleanProductPerformanceFirstGroup(urunIlkGrubu) + cost, usd = normalizeProductPerformanceCostPair(cost, usd) var lp any if lastPricing.Valid { lp = lastPricing.Time @@ -1063,7 +1160,7 @@ func ListProductPerformance(ctx context.Context, pg *sql.DB, f ProductPerformanc } limit := f.Limit if limit <= 0 { - limit = 100 + limit = 50000 } else if limit > 50000 { limit = 50000 } @@ -1103,6 +1200,7 @@ LIMIT $`+fmt.Sprint(len(args)-1)+` OFFSET $`+fmt.Sprint(len(args)), args...) } defer rows.Close() out := make([]models.ProductPerformanceRow, 0, limit) + colorCodes := make([]string, 0, limit) for rows.Next() { var r models.ProductPerformanceRow if err := rows.Scan( @@ -1122,9 +1220,17 @@ LIMIT $`+fmt.Sprint(len(args)-1)+` OFFSET $`+fmt.Sprint(len(args)), args...) return nil, 0, err } r.UrunIlkGrubu = cleanProductPerformanceFirstGroup(r.UrunIlkGrubu) + colorCodes = append(colorCodes, r.ColorCode) out = append(out, r) } - return out, total, rows.Err() + if err := rows.Err(); err != nil { + return nil, 0, err + } + colorDescriptions := productPerformanceColorDescriptions(ctx, colorCodes) + for i := range out { + out[i].ColorDescription = colorDescriptions[normalizeProductPerformanceCode(out[i].ColorCode)] + } + return out, total, nil } func GetProductPerformanceSummary(ctx context.Context, pg *sql.DB) (models.ProductPerformanceSummary, error) { @@ -1141,6 +1247,7 @@ KPI AS ( SELECT * FROM mk_product_performance_kpi_daily WHERE kpi_date = (SELECT kpi_date FROM Latest) + AND upper(btrim(COALESCE(urun_ilk_grubu,''))) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'DIGER') ), VariantStock AS ( SELECT @@ -1167,6 +1274,7 @@ Sales90 AS ( COUNT(DISTINCT NULLIF(customer_code, '-')) AS customer_count_90d FROM mk_product_performance_sales_daily WHERE sales_date BETWEEN (SELECT kpi_date FROM Latest) - INTERVAL '89 days' AND (SELECT kpi_date FROM Latest) + AND upper(btrim(COALESCE(urun_ilk_grubu,''))) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'DIGER') ) SELECT COALESCE(to_char(MAX(kpi_date),'YYYY-MM-DD'),''), @@ -1195,7 +1303,7 @@ func ListProductPerformanceGeneral(ctx context.Context, pg *sql.DB, limit int) ( return nil, err } if limit <= 0 { - limit = 500 + limit = 50000 } else if limit > 50000 { limit = 50000 } @@ -1236,6 +1344,7 @@ SalesAgg AS ( COALESCE(SUM(invoice_count),0)::integer AS invoice_count_total FROM mk_product_performance_sales_daily, Bounds WHERE sales_date BETWEEN Bounds.period_start AND Bounds.period_end + AND upper(btrim(COALESCE(urun_ilk_grubu,''))) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'DIGER') GROUP BY product_code, color_code, yaka_kodu, market_key ), StockAgg AS ( @@ -1287,6 +1396,7 @@ Dim AS ( 1 AS src_rank FROM mk_product_performance_kpi_daily WHERE kpi_date = (SELECT MAX(kpi_date) FROM mk_product_performance_kpi_daily) + AND upper(btrim(COALESCE(urun_ilk_grubu,''))) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'DIGER') UNION ALL SELECT product_code, @@ -1302,6 +1412,7 @@ Dim AS ( MAX(urun_alt_grubu) AS urun_alt_grubu, 2 AS src_rank FROM mk_product_performance_sales_daily + WHERE upper(btrim(COALESCE(urun_ilk_grubu,''))) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'DIGER') GROUP BY product_code, color_code, yaka_kodu ) x ORDER BY product_code, color_code, yaka_kodu, src_rank @@ -1333,6 +1444,7 @@ StockOnly AS ( AND d.color_code = s.color_code AND d.yaka_kodu = s.yaka_kodu WHERE s.stock_date = (SELECT stock_date FROM LatestStockDate) + AND upper(btrim(COALESCE(d.urun_ilk_grubu,''))) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'DIGER') AND NOT EXISTS ( SELECT 1 FROM SalesAgg a @@ -1356,6 +1468,7 @@ Spread AS ( COUNT(DISTINCT NULLIF(customer_code, '-'))::integer AS customer_count_total_all FROM mk_product_performance_sales_daily, Bounds WHERE sales_date BETWEEN Bounds.period_start AND Bounds.period_end + AND upper(btrim(COALESCE(urun_ilk_grubu,''))) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'DIGER') GROUP BY product_code, color_code, yaka_kodu ), Scored AS ( @@ -1463,6 +1576,7 @@ LIMIT $1 } defer rows.Close() out := make([]models.ProductPerformanceGeneralRow, 0, limit) + colorCodes := make([]string, 0, limit) for rows.Next() { var r models.ProductPerformanceGeneralRow if err := rows.Scan( @@ -1476,9 +1590,17 @@ LIMIT $1 return nil, err } r.UrunIlkGrubu = cleanProductPerformanceFirstGroup(r.UrunIlkGrubu) + colorCodes = append(colorCodes, r.ColorCode) out = append(out, r) } - return out, rows.Err() + if err := rows.Err(); err != nil { + return nil, err + } + colorDescriptions := productPerformanceColorDescriptions(ctx, colorCodes) + for i := range out { + out[i].ColorDescription = colorDescriptions[normalizeProductPerformanceCode(out[i].ColorCode)] + } + return out, nil } func ListProductPerformanceOrderAnalysis(ctx context.Context, pg *sql.DB, limit int) ([]models.ProductPerformanceOrderAnalysisRow, error) { @@ -1489,7 +1611,7 @@ func ListProductPerformanceOrderAnalysis(ctx context.Context, pg *sql.DB, limit return nil, err } if limit <= 0 { - limit = 500 + limit = 50000 } else if limit > 50000 { limit = 50000 } @@ -1691,6 +1813,7 @@ ORDER BY SUM(o.AmountUSD) DESC, SUM(o.Qty) DESC, o.ProductCode, o.ColorCode, o.Y out := make([]models.ProductPerformanceOrderAnalysisRow, 0, limit) productCodes := make([]string, 0, limit) stockKeys := make([]string, 0, limit) + colorCodes := make([]string, 0, limit) seenProducts := map[string]bool{} seenStockKeys := map[string]bool{} for rows.Next() { @@ -1704,6 +1827,7 @@ ORDER BY SUM(o.AmountUSD) DESC, SUM(o.Qty) DESC, o.ProductCode, o.ColorCode, o.Y } normalizeProductPerformanceVariantCodes(&r.ProductCode, &r.ColorCode, &r.YakaKodu) r.AskiliYan = cleanProductPerformanceOptionalAttr(r.AskiliYan) + colorCodes = append(colorCodes, r.ColorCode) out = append(out, r) if !seenProducts[r.ProductCode] { seenProducts[r.ProductCode] = true @@ -1731,10 +1855,15 @@ ORDER BY SUM(o.AmountUSD) DESC, SUM(o.Qty) DESC, o.ProductCode, o.ColorCode, o.Y if err != nil { return nil, err } + colorDescriptions := productPerformanceColorDescriptions(ctx, colorCodes) + filtered := out[:0] for i := range out { row := &out[i] price := priceByProduct[normalizeProductPerformanceProductCode(row.ProductCode)] attr := attrByKey[productPerformanceVariantKey(row.ProductCode, row.ColorCode, row.YakaKodu)] + if isExcludedProductPerformanceFirstGroup(attr.urunIlkGrubu) { + continue + } if strings.TrimSpace(row.ItemDescription) == "" { row.ItemDescription = attr.itemDescription } @@ -1765,9 +1894,11 @@ ORDER BY SUM(o.AmountUSD) DESC, SUM(o.Qty) DESC, o.ProductCode, o.ColorCode, o.Y if row.OrderUSD != 0 { row.ExpectedMarginCost = row.ExpectedProfitCostUSD / row.OrderUSD } + row.ColorDescription = colorDescriptions[normalizeProductPerformanceCode(row.ColorCode)] row.PerformanceBucket, row.Recommendation = productPerformanceOrderRecommendation(*row) + filtered = append(filtered, *row) } - return out, nil + return filtered, nil } func ListProductPerformanceOrderGroups(ctx context.Context, pg *sql.DB, breakdown string, limit int) ([]models.ProductPerformanceOrderGroupRow, error) { @@ -1781,8 +1912,10 @@ func ListProductPerformanceOrderGroups(ctx context.Context, pg *sql.DB, breakdow if mode != "customer" { mode = "market" } - if limit <= 0 || limit > 1000 { - limit = 500 + if limit <= 0 { + limit = 50000 + } else if limit > 50000 { + limit = 50000 } rows, err := db.MssqlDB.QueryContext(ctx, ` @@ -2015,8 +2148,10 @@ func ListProductPerformanceOrderProductCustomers(ctx context.Context, pg *sql.DB if err := EnsureProductPerformanceTables(pg); err != nil { return nil, err } - if limit <= 0 || limit > 1000 { - limit = 500 + if limit <= 0 { + limit = 50000 + } else if limit > 50000 { + limit = 50000 } rows, err := db.MssqlDB.QueryContext(ctx, ` WITH OpenOrderLines AS ( @@ -2074,6 +2209,7 @@ ORDER BY SUM(AmountUSD) DESC, SUM(Qty) DESC out := make([]models.ProductPerformanceOrderProductCustomerRow, 0, limit) productCodes := make([]string, 0, limit) stockKeys := make([]string, 0, limit) + colorCodes := make([]string, 0, limit) seenProducts := map[string]bool{} seenStockKeys := map[string]bool{} for rows.Next() { @@ -2082,6 +2218,7 @@ ORDER BY SUM(AmountUSD) DESC, SUM(Qty) DESC return nil, err } normalizeProductPerformanceVariantCodes(&r.ProductCode, &r.ColorCode, &r.YakaKodu) + colorCodes = append(colorCodes, r.ColorCode) out = append(out, r) if !seenProducts[r.ProductCode] { seenProducts[r.ProductCode] = true @@ -2108,10 +2245,15 @@ ORDER BY SUM(AmountUSD) DESC, SUM(Qty) DESC if err != nil { return nil, err } + colorDescriptions := productPerformanceColorDescriptions(ctx, colorCodes) + filtered := out[:0] for i := range out { row := &out[i] price := priceByProduct[normalizeProductPerformanceProductCode(row.ProductCode)] attr := attrByKey[productPerformanceVariantKey(row.ProductCode, row.ColorCode, row.YakaKodu)] + if isExcludedProductPerformanceFirstGroup(attr.urunIlkGrubu) { + continue + } if strings.TrimSpace(row.ItemDescription) == "" { row.ItemDescription = attr.itemDescription } @@ -2130,9 +2272,11 @@ ORDER BY SUM(AmountUSD) DESC, SUM(Qty) DESC row.ExpectedMarginBase = row.ExpectedProfitBaseUSD / row.OrderUSD row.ExpectedMarginCost = row.ExpectedProfitCostUSD / row.OrderUSD } + row.ColorDescription = colorDescriptions[normalizeProductPerformanceCode(row.ColorCode)] row.PerformanceBucket, row.Recommendation = productPerformanceOrderProductCustomerRecommendation(*row) + filtered = append(filtered, *row) } - return out, nil + return filtered, nil } func ListProductPerformanceOrderMarketDetails(ctx context.Context, pg *sql.DB, limit int) ([]models.ProductPerformanceOrderMarketDetailRow, error) { @@ -2199,6 +2343,7 @@ ORDER BY MarketKey, CustomerName, OrderDate DESC, OrderNumber, SUM(AmountUSD) DE out := make([]models.ProductPerformanceOrderMarketDetailRow, 0, limit) productCodes := make([]string, 0, limit) stockKeys := make([]string, 0, limit) + colorCodes := make([]string, 0, limit) seenProducts := map[string]bool{} seenStockKeys := map[string]bool{} for rows.Next() { @@ -2207,6 +2352,7 @@ ORDER BY MarketKey, CustomerName, OrderDate DESC, OrderNumber, SUM(AmountUSD) DE return nil, err } normalizeProductPerformanceVariantCodes(&r.ProductCode, &r.ColorCode, &r.YakaKodu) + colorCodes = append(colorCodes, r.ColorCode) out = append(out, r) if !seenProducts[r.ProductCode] { seenProducts[r.ProductCode] = true @@ -2229,9 +2375,19 @@ ORDER BY MarketKey, CustomerName, OrderDate DESC, OrderNumber, SUM(AmountUSD) DE if err != nil { return nil, err } + attrByKey, err := productPerformanceAttrLookup(ctx, pg, stockKeys) + if err != nil { + return nil, err + } + colorDescriptions := productPerformanceColorDescriptions(ctx, colorCodes) + filtered := out[:0] for i := range out { row := &out[i] price := priceByProduct[normalizeProductPerformanceProductCode(row.ProductCode)] + attr := attrByKey[productPerformanceVariantKey(row.ProductCode, row.ColorCode, row.YakaKodu)] + if isExcludedProductPerformanceFirstGroup(attr.urunIlkGrubu) { + continue + } row.CostPriceUSD = price.cost row.BasePriceUSD = price.base row.StockQty = stockByKey[productPerformanceVariantKey(row.ProductCode, row.ColorCode, row.YakaKodu)] @@ -2242,17 +2398,21 @@ ORDER BY MarketKey, CustomerName, OrderDate DESC, OrderNumber, SUM(AmountUSD) DE row.ExpectedMarginBase = row.ExpectedProfitBaseUSD / row.OrderUSD row.ExpectedMarginCost = row.ExpectedProfitCostUSD / row.OrderUSD } + row.ColorDescription = colorDescriptions[normalizeProductPerformanceCode(row.ColorCode)] row.PerformanceBucket, row.Recommendation = productPerformanceOrderMarketDetailRecommendation(*row) + filtered = append(filtered, *row) } - return out, nil + return filtered, nil } func ListProductPerformanceMarkets(ctx context.Context, pg *sql.DB, limit int) ([]models.ProductPerformanceMarketRow, error) { if err := EnsureProductPerformanceTables(pg); err != nil { return nil, err } - if limit <= 0 || limit > 500 { - limit = 100 + if limit <= 0 { + limit = 50000 + } else if limit > 50000 { + limit = 50000 } rows, err := pg.QueryContext(ctx, ` SELECT @@ -2277,6 +2437,7 @@ SELECT COALESCE(AVG(NULLIF(stock_days_90d,0)),0) AS avg_stock_days_90d FROM mk_product_performance_kpi_daily WHERE kpi_date = (SELECT MAX(kpi_date) FROM mk_product_performance_kpi_daily) + AND upper(btrim(COALESCE(urun_ilk_grubu,''))) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'DIGER') GROUP BY market_key ORDER BY risk_stock_cost_value_usd DESC, stock_cost_value_usd DESC, sales_usd_90d DESC LIMIT $1 @@ -2306,8 +2467,10 @@ func ListProductPerformanceCountries(ctx context.Context, pg *sql.DB, limit int) if err := EnsureProductPerformanceTables(pg); err != nil { return nil, err } - if limit <= 0 || limit > 500 { - limit = 100 + if limit <= 0 { + limit = 50000 + } else if limit > 50000 { + limit = 50000 } rows, err := pg.QueryContext(ctx, ` SELECT @@ -2330,6 +2493,7 @@ SELECT COALESCE(SUM(sales_usd) FILTER (WHERE sales_date >= current_date - INTERVAL '364 days'),0) AS sales_usd_365d FROM mk_product_performance_sales_daily WHERE sales_date >= current_date - INTERVAL '364 days' + AND upper(btrim(COALESCE(urun_ilk_grubu,''))) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'DIGER') GROUP BY customer_country, customer_segment, market_key ORDER BY sales_usd_90d DESC, sales_qty_90d DESC LIMIT $1 @@ -2357,8 +2521,10 @@ func ListProductPerformanceCustomers(ctx context.Context, pg *sql.DB, breakdown if err := EnsureProductPerformanceTables(pg); err != nil { return nil, err } - if limit <= 0 || limit > 500 { - limit = 100 + if limit <= 0 { + limit = 50000 + } else if limit > 50000 { + limit = 50000 } mode := strings.ToLower(strings.TrimSpace(breakdown)) if mode == "" { @@ -2407,6 +2573,7 @@ SELECT FROM mk_product_performance_sales_daily WHERE sales_date >= current_date - INTERVAL '364 days' AND COALESCE(customer_code,'') <> '' + AND upper(btrim(COALESCE(urun_ilk_grubu,''))) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'DIGER') GROUP BY %s ORDER BY sales_usd_90d DESC, sales_qty_90d DESC LIMIT $1 @@ -2437,7 +2604,7 @@ func ListProductPerformanceSalesBreakdown(ctx context.Context, pg *sql.DB, break return nil, err } if limit <= 0 { - limit = 500 + limit = 50000 } else if limit > 50000 { limit = 50000 } @@ -2566,6 +2733,7 @@ WITH LatestKPI AS ( urun_alt_grubu FROM mk_product_performance_kpi_daily WHERE kpi_date = (SELECT MAX(kpi_date) FROM mk_product_performance_kpi_daily) + AND upper(btrim(COALESCE(urun_ilk_grubu,''))) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'DIGER') ORDER BY product_code, color_code, yaka_kodu, performance_score DESC ), StockAgg AS ( @@ -2726,6 +2894,7 @@ Agg AS ( LEFT JOIN StockTotalStart stotal ON stotal.product_code = s.product_code AND stotal.color_code = s.color_code AND stotal.yaka_kodu = s.yaka_kodu ) s WHERE sales_date >= DATE '2022-01-01' + AND upper(btrim(COALESCE(urun_ilk_grubu,''))) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'DIGER') GROUP BY %s ), Enriched AS ( @@ -2913,6 +3082,7 @@ LIMIT $1 } defer rows.Close() out := make([]models.ProductPerformanceSalesBreakdownRow, 0, limit) + colorCodes := make([]string, 0, limit) for rows.Next() { var r models.ProductPerformanceSalesBreakdownRow if err := rows.Scan( @@ -2935,9 +3105,17 @@ LIMIT $1 ); err != nil { return nil, err } + colorCodes = append(colorCodes, r.ColorCode) out = append(out, r) } - return out, rows.Err() + if err := rows.Err(); err != nil { + return nil, err + } + colorDescriptions := productPerformanceColorDescriptions(ctx, colorCodes) + for i := range out { + out[i].ColorDescription = colorDescriptions[normalizeProductPerformanceCode(out[i].ColorCode)] + } + return out, nil } type ProductPerformanceGroupedRequest struct { @@ -3135,9 +3313,24 @@ FROM ( CASE WHEN SUM(CASE WHEN stock_variant_rank = 1 THEN stock_qty ELSE 0 END) > 0 THEN SUM(CASE WHEN stock_variant_rank = 1 THEN stock_days_total * stock_qty ELSE 0 END) / NULLIF(SUM(CASE WHEN stock_variant_rank = 1 THEN stock_qty ELSE 0 END),0) ELSE 0 END AS stock_days_total, CASE WHEN SUM(sales_qty_90d) > 0 THEN SUM(sales_usd_90d) / NULLIF(SUM(sales_qty_90d),0) ELSE 0 END AS avg_price_usd_90d, CASE WHEN SUM(sales_qty_180d) > 0 THEN SUM(sales_usd_180d) / NULLIF(SUM(sales_qty_180d),0) ELSE 0 END AS avg_price_usd_180d, - CASE WHEN SUM(CASE WHEN stock_variant_rank = 1 THEN stock_qty ELSE 0 END) > 0 THEN SUM(CASE WHEN stock_variant_rank = 1 THEN cost_price_usd * stock_qty ELSE 0 END) / NULLIF(SUM(CASE WHEN stock_variant_rank = 1 THEN stock_qty ELSE 0 END),0) ELSE 0 END AS cost_price_usd, - CASE WHEN SUM(CASE WHEN stock_variant_rank = 1 THEN stock_qty ELSE 0 END) > 0 THEN SUM(CASE WHEN stock_variant_rank = 1 THEN base_price_usd * stock_qty ELSE 0 END) / NULLIF(SUM(CASE WHEN stock_variant_rank = 1 THEN stock_qty ELSE 0 END),0) ELSE 0 END AS base_price_usd, - CASE WHEN SUM(CASE WHEN stock_variant_rank = 1 THEN stock_qty ELSE 0 END) > 0 THEN SUM(CASE WHEN stock_variant_rank = 1 THEN base_price_try * stock_qty ELSE 0 END) / NULLIF(SUM(CASE WHEN stock_variant_rank = 1 THEN stock_qty ELSE 0 END),0) ELSE 0 END AS base_price_try, + CASE + WHEN SUM(CASE WHEN sales_qty_total > 0 THEN sales_qty_total WHEN stock_variant_rank = 1 THEN stock_qty ELSE 0 END) > 0 + THEN SUM(CASE WHEN sales_qty_total > 0 THEN cost_price_usd * sales_qty_total WHEN stock_variant_rank = 1 THEN cost_price_usd * stock_qty ELSE 0 END) + / NULLIF(SUM(CASE WHEN sales_qty_total > 0 THEN sales_qty_total WHEN stock_variant_rank = 1 THEN stock_qty ELSE 0 END),0) + ELSE 0 + END AS cost_price_usd, + CASE + WHEN SUM(CASE WHEN sales_qty_total > 0 THEN sales_qty_total WHEN stock_variant_rank = 1 THEN stock_qty ELSE 0 END) > 0 + THEN SUM(CASE WHEN sales_qty_total > 0 THEN base_price_usd * sales_qty_total WHEN stock_variant_rank = 1 THEN base_price_usd * stock_qty ELSE 0 END) + / NULLIF(SUM(CASE WHEN sales_qty_total > 0 THEN sales_qty_total WHEN stock_variant_rank = 1 THEN stock_qty ELSE 0 END),0) + ELSE 0 + END AS base_price_usd, + CASE + WHEN SUM(CASE WHEN sales_qty_total > 0 THEN sales_qty_total WHEN stock_variant_rank = 1 THEN stock_qty ELSE 0 END) > 0 + THEN SUM(CASE WHEN sales_qty_total > 0 THEN base_price_try * sales_qty_total WHEN stock_variant_rank = 1 THEN base_price_try * stock_qty ELSE 0 END) + / NULLIF(SUM(CASE WHEN sales_qty_total > 0 THEN sales_qty_total WHEN stock_variant_rank = 1 THEN stock_qty ELSE 0 END),0) + ELSE 0 + END AS base_price_try, COALESCE(SUM(gross_profit_usd_90d),0) AS gross_profit_usd_90d, COALESCE(SUM(gross_profit_usd_180d),0) AS gross_profit_usd_180d, CASE WHEN SUM(sales_usd_90d) > 0 THEN SUM(gross_profit_usd_90d) / NULLIF(SUM(sales_usd_90d),0) ELSE 0 END AS gross_margin_90d, @@ -3201,6 +3394,7 @@ func queryProductPerformanceJSONRows(ctx context.Context, pg *sql.DB, query stri } defer rows.Close() out := []map[string]any{} + colorCodes := []string{} for rows.Next() { var raw []byte if err := rows.Scan(&raw); err != nil { @@ -3210,9 +3404,17 @@ func queryProductPerformanceJSONRows(ctx context.Context, pg *sql.DB, query stri if err := json.Unmarshal(raw, &row); err != nil { return nil, err } + colorCodes = append(colorCodes, stringFromMap(row, "color_code")) out = append(out, row) } - return out, rows.Err() + if err := rows.Err(); err != nil { + return nil, err + } + colorDescriptions := productPerformanceColorDescriptions(ctx, colorCodes) + for _, row := range out { + row["color_description"] = colorDescriptions[normalizeProductPerformanceCode(stringFromMap(row, "color_code"))] + } + return out, nil } func productPerformanceSQLSourceCTE(mode string) string { @@ -3312,7 +3514,8 @@ Source AS ( COALESCE(performance_bucket,'') AS performance_bucket, COALESCE(stock_qty,0) * COALESCE(cost_price_usd,0) AS idle_cost_usd FROM mk_product_performance_kpi_daily - WHERE kpi_date = (SELECT MAX(kpi_date) FROM mk_product_performance_kpi_daily)` + idleWhere + ` + WHERE kpi_date = (SELECT MAX(kpi_date) FROM mk_product_performance_kpi_daily) + AND upper(btrim(COALESCE(urun_ilk_grubu,''))) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'DIGER')` + idleWhere + ` )` } @@ -3980,6 +4183,7 @@ FROM mk_product_performance_sales_daily WHERE product_code=$1 AND color_code=$2 AND yaka_kodu=$3 + AND upper(btrim(COALESCE(urun_ilk_grubu,''))) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'DIGER') ORDER BY sales_usd DESC, sales_date DESC LIMIT $4 `, strings.TrimSpace(productCode), strings.TrimSpace(colorCode), strings.TrimSpace(yakaKodu), limit) @@ -4108,7 +4312,7 @@ ORDER BY SizeCode } func productPerformanceWhere(f ProductPerformanceFilters) (string, []any) { - parts := make([]string, 0, 6) + parts := []string{" AND " + productPerformanceAllowedFirstGroupSQL("urun_ilk_grubu")} args := make([]any, 0, 6) add := func(cond string, value any) { args = append(args, value) @@ -4219,6 +4423,28 @@ func productPerformanceVariantKey(productCode, colorCode, yakaKodu string) strin return normalizeProductPerformanceProductCode(productCode) + "|" + strings.TrimSpace(colorCode) + "|" + strings.TrimSpace(yakaKodu) } +func normalizeProductPerformanceCostPair(costPriceUSD, basePriceUSD float64) (float64, float64) { + if costPriceUSD > 0 && basePriceUSD > 0 && costPriceUSD < basePriceUSD { + return basePriceUSD, costPriceUSD + } + return costPriceUSD, basePriceUSD +} + +const productPerformanceExcludedFirstGroupsSQL = "('MALZEMELI FASON', 'MALZEMESIZ FASON', 'DIGER')" + +func productPerformanceAllowedFirstGroupSQL(expr string) string { + return "upper(btrim(COALESCE(" + expr + ",''))) NOT IN " + productPerformanceExcludedFirstGroupsSQL +} + +func isExcludedProductPerformanceFirstGroup(value string) bool { + switch strings.ToUpper(strings.TrimSpace(value)) { + case "MALZEMELI FASON", "MALZEMESIZ FASON", "DIGER": + return true + default: + return false + } +} + func productPerformancePriceLookup(ctx context.Context, pg *sql.DB, productCodes []string) (map[string]productPerformancePriceInfo, error) { out := make(map[string]productPerformancePriceInfo, len(productCodes)) if len(productCodes) == 0 { @@ -4252,6 +4478,7 @@ WHERE product_code = ANY($1) if err := rows.Scan(&productCode, &p.cost, &p.base); err != nil { return nil, err } + p.cost, p.base = normalizeProductPerformanceCostPair(p.cost, p.base) out[normalizeProductPerformanceProductCode(productCode)] = p } return out, rows.Err() @@ -4280,6 +4507,7 @@ SELECT DISTINCT ON (product_code, color_code, yaka_kodu) FROM mk_product_performance_kpi_daily WHERE kpi_date = (SELECT kpi_date FROM Latest) AND product_code || '|' || color_code || '|' || yaka_kodu = ANY($1) + AND upper(btrim(COALESCE(urun_ilk_grubu,''))) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'DIGER') ORDER BY product_code, color_code, yaka_kodu, performance_score DESC `, pq.Array(variantKeys)) if err != nil { diff --git a/svc/queries/product_performance_sql.go b/svc/queries/product_performance_sql.go index 4bc897b..b3145c5 100644 --- a/svc/queries/product_performance_sql.go +++ b/svc/queries/product_performance_sql.go @@ -455,6 +455,7 @@ SalesAgg AS ( FROM mk_product_performance_sales_daily WHERE sales_date >= DATE '2022-01-01' AND sales_date <= $1::date + AND upper(btrim(COALESCE(urun_ilk_grubu,''))) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'DIGER') GROUP BY product_code, color_code, yaka_kodu, market_key ), Scope AS ( @@ -476,7 +477,7 @@ Scope AS ( '' AS seri, '' AS yas_grubu, '' AS askili_yan, - '' AS urun_ilk_grubu, + COALESCE(pd.urun_ilk_grubu, '') AS urun_ilk_grubu, '' AS urun_ana_grubu, '' AS urun_alt_grubu, 0 AS sales_qty_30d, @@ -494,7 +495,9 @@ Scope AS ( NULL::date AS last_sale_date, '' AS last_ref_number FROM LatestStock ls + LEFT JOIN mk_product_performance_price_dim pd ON pd.product_code=ls.product_code WHERE COALESCE(ls.stock_qty, 0) <> 0 + AND upper(btrim(COALESCE(pd.urun_ilk_grubu,''))) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'DIGER') AND NOT EXISTS ( SELECT 1 FROM SalesAgg sa @@ -503,6 +506,20 @@ Scope AS ( AND sa.yaka_kodu = ls.yaka_kodu ) ), +PriceDim AS ( + SELECT + product_code, + CASE + WHEN cost_price_usd > 0 AND base_price_usd > 0 AND cost_price_usd < base_price_usd THEN base_price_usd + ELSE cost_price_usd + END AS cost_price_usd, + CASE + WHEN cost_price_usd > 0 AND base_price_usd > 0 AND cost_price_usd < base_price_usd THEN cost_price_usd + ELSE base_price_usd + END AS base_price_usd, + base_price_try + FROM mk_product_performance_price_dim +), Base AS ( SELECT s.*, @@ -538,7 +555,8 @@ Base AS ( LEFT JOIN Stock180Start s180 ON s180.product_code=s.product_code AND s180.color_code=s.color_code AND s180.yaka_kodu=s.yaka_kodu LEFT JOIN Stock365Start s365 ON s365.product_code=s.product_code AND s365.color_code=s.color_code AND s365.yaka_kodu=s.yaka_kodu LEFT JOIN StockTotalStart stotal ON stotal.product_code=s.product_code AND stotal.color_code=s.color_code AND stotal.yaka_kodu=s.yaka_kodu - LEFT JOIN mk_product_performance_price_dim pd ON pd.product_code=s.product_code + LEFT JOIN PriceDim pd ON pd.product_code=s.product_code + WHERE upper(btrim(COALESCE(NULLIF(s.urun_ilk_grubu,''), pd.urun_ilk_grubu, ''))) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'DIGER') ), Spread AS ( SELECT diff --git a/svc/routes/product_performance.go b/svc/routes/product_performance.go index cf86eda..1dfa46c 100644 --- a/svc/routes/product_performance.go +++ b/svc/routes/product_performance.go @@ -28,7 +28,7 @@ func GetProductPerformanceHandler(pg *sql.DB) http.HandlerFunc { Bucket: strings.TrimSpace(r.URL.Query().Get("bucket")), SortBy: strings.TrimSpace(r.URL.Query().Get("sort_by")), Descending: boolQuery(r, "descending", true), - Limit: intQuery(r, "limit", 100), + Limit: intQuery(r, "limit", 50000), Page: intQuery(r, "page", 1), } rows, total, err := queries.ListProductPerformance(ctx, pg, f) @@ -109,7 +109,7 @@ func GetProductPerformanceGeneralHandler(pg *sql.DB) http.HandlerFunc { ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 60*time.Second) defer cancel() - rows, err := queries.ListProductPerformanceGeneral(ctx, pg, intQuery(r, "limit", 500)) + rows, err := queries.ListProductPerformanceGeneral(ctx, pg, intQuery(r, "limit", 50000)) if err != nil { http.Error(w, "genel urun performans KPI alinamadi: "+err.Error(), http.StatusInternalServerError) return @@ -125,7 +125,7 @@ func GetProductPerformanceOrdersHandler(pg *sql.DB) http.HandlerFunc { ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 90*time.Second) defer cancel() - rows, err := queries.ListProductPerformanceOrderAnalysis(ctx, pg, intQuery(r, "limit", 500)) + rows, err := queries.ListProductPerformanceOrderAnalysis(ctx, pg, intQuery(r, "limit", 50000)) if err != nil { http.Error(w, "siparis analiz verisi alinamadi: "+err.Error(), http.StatusInternalServerError) return @@ -145,7 +145,7 @@ func GetProductPerformanceOrderGroupsHandler(pg *sql.DB) http.HandlerFunc { ctx, pg, strings.TrimSpace(r.URL.Query().Get("breakdown")), - intQuery(r, "limit", 500), + intQuery(r, "limit", 50000), ) if err != nil { http.Error(w, "siparis grup analiz verisi alinamadi: "+err.Error(), http.StatusInternalServerError) @@ -162,7 +162,7 @@ func GetProductPerformanceOrderProductCustomersHandler(pg *sql.DB) http.HandlerF ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 90*time.Second) defer cancel() - rows, err := queries.ListProductPerformanceOrderProductCustomers(ctx, pg, intQuery(r, "limit", 500)) + rows, err := queries.ListProductPerformanceOrderProductCustomers(ctx, pg, intQuery(r, "limit", 50000)) if err != nil { http.Error(w, "urun musteri siparis analiz verisi alinamadi: "+err.Error(), http.StatusInternalServerError) return @@ -178,7 +178,7 @@ func GetProductPerformanceOrderMarketDetailsHandler(pg *sql.DB) http.HandlerFunc ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 90*time.Second) defer cancel() - rows, err := queries.ListProductPerformanceOrderMarketDetails(ctx, pg, intQuery(r, "limit", 800)) + rows, err := queries.ListProductPerformanceOrderMarketDetails(ctx, pg, intQuery(r, "limit", 50000)) if err != nil { http.Error(w, "piyasa siparis detay analiz verisi alinamadi: "+err.Error(), http.StatusInternalServerError) return @@ -194,7 +194,7 @@ func GetProductPerformanceMarketsHandler(pg *sql.DB) http.HandlerFunc { ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 30*time.Second) defer cancel() - rows, err := queries.ListProductPerformanceMarkets(ctx, pg, intQuery(r, "limit", 100)) + rows, err := queries.ListProductPerformanceMarkets(ctx, pg, intQuery(r, "limit", 50000)) if err != nil { http.Error(w, "piyasa performans ozeti alinamadi: "+err.Error(), http.StatusInternalServerError) return @@ -210,7 +210,7 @@ func GetProductPerformanceCountriesHandler(pg *sql.DB) http.HandlerFunc { ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 30*time.Second) defer cancel() - rows, err := queries.ListProductPerformanceCountries(ctx, pg, intQuery(r, "limit", 100)) + rows, err := queries.ListProductPerformanceCountries(ctx, pg, intQuery(r, "limit", 50000)) if err != nil { http.Error(w, "ulke performans ozeti alinamadi: "+err.Error(), http.StatusInternalServerError) return @@ -230,7 +230,7 @@ func GetProductPerformanceCustomersHandler(pg *sql.DB) http.HandlerFunc { ctx, pg, strings.TrimSpace(r.URL.Query().Get("breakdown")), - intQuery(r, "limit", 100), + intQuery(r, "limit", 50000), ) if err != nil { http.Error(w, "musteri performans kirilimi alinamadi: "+err.Error(), http.StatusInternalServerError) @@ -251,7 +251,7 @@ func GetProductPerformanceSalesBreakdownHandler(pg *sql.DB) http.HandlerFunc { ctx, pg, strings.TrimSpace(r.URL.Query().Get("mode")), - intQuery(r, "limit", 500), + intQuery(r, "limit", 50000), ) if err != nil { http.Error(w, "satis kpi kirilimi alinamadi: "+err.Error(), http.StatusInternalServerError) diff --git a/ui/src/pages/ProductPerformanceProfitability.vue b/ui/src/pages/ProductPerformanceProfitability.vue index 9a6ecef..cf9042e 100644 --- a/ui/src/pages/ProductPerformanceProfitability.vue +++ b/ui/src/pages/ProductPerformanceProfitability.vue @@ -95,7 +95,7 @@ :rows="filteredGeneralRows" :columns="generalColumns" :loading="loading" - :pagination="{ rowsPerPage: 100, sortBy: 'performance_score_total', descending: true }" + :pagination="{ rowsPerPage: 0, sortBy: 'performance_score_total', descending: true }" virtual-scroll :virtual-scroll-item-size="64" > @@ -206,7 +206,7 @@ :rows="displayOrderProductCustomerTableRows" :columns="orderProductCustomerColumns" :loading="loading || backendGroupedLoading" - :pagination="{ rowsPerPage: 100, sortBy: 'order_usd', descending: true }" + :pagination="{ rowsPerPage: 0, sortBy: 'order_usd', descending: true }" :sort-method="sortProductGroupedTableRows" virtual-scroll :virtual-scroll-item-size="64" @@ -354,7 +354,7 @@ :icon="isGroupExpanded(props.row.key) ? 'expand_more' : 'chevron_right'" @click.stop="toggleGroup(props.row.key)" /> - {{ props.row.label }} + {{ groupDisplayLabel(props.row) }} {{ formatGroupCell(props.row, col) }} @@ -396,7 +396,7 @@ :rows="displayOrderMarketDetailTableRows" :columns="orderMarketDetailColumns" :loading="loading || backendGroupedLoading" - :pagination="{ rowsPerPage: 100, sortBy: 'order_date', descending: true }" + :pagination="{ rowsPerPage: 0, sortBy: 'order_date', descending: true }" :sort-method="sortProductGroupedTableRows" virtual-scroll :virtual-scroll-item-size="64" @@ -508,7 +508,7 @@ :icon="isGroupExpanded(props.row.key) ? 'expand_more' : 'chevron_right'" @click.stop="toggleGroup(props.row.key)" /> - {{ props.row.label }} + {{ groupDisplayLabel(props.row) }} {{ formatGroupCell(props.row, col) }} @@ -646,7 +646,7 @@ :icon="isGroupExpanded(props.row.key) ? 'expand_more' : 'chevron_right'" @click.stop="toggleGroup(props.row.key)" /> - {{ props.row.label }} + {{ groupDisplayLabel(props.row) }} {{ formatGroupCell(props.row, col.name) }} @@ -688,7 +688,7 @@ :rows="displayIdleTableRows" :columns="idleColumns" :loading="loading || backendGroupedLoading" - :pagination="{ rowsPerPage: 100 }" + :pagination="{ rowsPerPage: 0 }" :sort-method="sortProductGroupedTableRows" virtual-scroll :virtual-scroll-item-size="64" @@ -832,7 +832,7 @@ :icon="isGroupExpanded(props.row.key) ? 'expand_more' : 'chevron_right'" @click.stop="toggleGroup(props.row.key)" /> - {{ props.row.label }} + {{ groupDisplayLabel(props.row) }} {{ formatGroupCell(props.row, col) }} @@ -874,7 +874,7 @@ :rows="filteredMarketRows" :columns="marketColumns" :loading="loading" - :pagination="{ rowsPerPage: 100 }" + :pagination="{ rowsPerPage: 0 }" virtual-scroll :virtual-scroll-item-size="64" > @@ -974,7 +974,7 @@ :rows="displayActiveSalesBreakdownTableRows" :columns="visibleSalesBreakdownColumns" :loading="loading || backendGroupedLoading" - :pagination="{ rowsPerPage: 100, sortBy: 'performance_score_total', descending: true }" + :pagination="{ rowsPerPage: 0, sortBy: 'performance_score_total', descending: true }" :sort-method="sortProductGroupedTableRows" virtual-scroll :virtual-scroll-item-size="64" @@ -1091,7 +1091,7 @@ :icon="isGroupExpanded(props.row.key) ? 'expand_more' : 'chevron_right'" @click.stop="toggleGroup(props.row.key)" /> - {{ props.row.label }} + {{ groupDisplayLabel(props.row) }} {{ formatGroupCell(props.row, col) }} @@ -1132,7 +1132,7 @@ :rows="filteredCustomerRows" :columns="customerColumns" :loading="loading" - :pagination="{ rowsPerPage: 100 }" + :pagination="{ rowsPerPage: 0 }" virtual-scroll :virtual-scroll-item-size="64" > @@ -1233,7 +1233,7 @@ :rows="filteredCountryRows" :columns="countryColumns" :loading="loading" - :pagination="{ rowsPerPage: 100 }" + :pagination="{ rowsPerPage: 0 }" virtual-scroll :virtual-scroll-item-size="64" > @@ -1476,10 +1476,26 @@ import { computed, onMounted, reactive, ref, watch } from 'vue' import { Notify } from 'quasar' import api from 'src/services/api' import { useProductPerformanceStore } from 'src/stores/productPerformanceStore' -import { formatMoney, formatNumber, formatPercent } from 'src/utils/formatters' const performanceStore = useProductPerformanceStore() +function formatNumber (value, fraction = 2) { + const n = Number(value || 0) + const digits = Math.max(0, Math.min(2, Number(fraction || 0))) + return n.toLocaleString('tr-TR', { + minimumFractionDigits: digits, + maximumFractionDigits: digits + }) +} + +function formatMoney (value, currency = 'USD') { + return `${formatNumber(value, 2)} ${currency}` +} + +function formatPercent (value) { + return `${formatNumber(Number(value || 0) * 100, 2)}%` +} + const loading = ref(false) const rows = ref([]) const generalRows = ref([]) @@ -1542,10 +1558,11 @@ const selectedExpandLevelKeysByTab = reactive({ order_market_details: ['market_key', 'customer_code', 'customer_name', 'urun_ilk_grubu', 'askili_yan'] }) const productKpiFetchLimit = 50000 +const reportFetchLimit = 50000 const maxBulkExpandKeys = 1200 const performanceTabs = [ { name: 'products', icon: 'inventory_2', label: 'Ürün KPI' }, - { name: 'sales_color_yaka_market_customer', icon: 'palette', label: 'Renk > Yaka > Piyasa > Müşteri' }, + { 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' }, { name: 'sales_country_segment_market_customer_product', icon: 'public', label: 'Ülke > Segment > Piyasa > Müşteri > Ürün Satış KPI' }, @@ -1556,7 +1573,7 @@ const pageBusy = computed(() => loading.value || backendGroupedLoading.value || const pagination = ref({ page: 1, - rowsPerPage: 100, + rowsPerPage: 0, sortBy: 'performance_score_total', descending: true }) @@ -1691,6 +1708,7 @@ const periodOrder = { const hiddenTableColumns = new Set(['item_description', 'performance_score']) const dimensionColumnNames = [ 'product_code', + 'color_yaka', 'color_code', 'yaka_kodu', 'item_description', @@ -2158,6 +2176,36 @@ function removeColumnsInPlace (targetColumns, names) { return targetColumns } +function ensureColorYakaColumn (targetColumns) { + const colorIndex = targetColumns.findIndex(col => col.name === 'color_code') + const yakaIndex = targetColumns.findIndex(col => col.name === 'yaka_kodu') + const insertAt = colorIndex >= 0 ? colorIndex : yakaIndex + if (insertAt >= 0 && !targetColumns.some(col => col.name === 'color_yaka')) { + targetColumns.splice(insertAt, 0, { + name: 'color_yaka', + label: 'Renk/Yaka', + field: row => colorYakaValue(row), + align: 'left', + sortable: true, + classes: 'color-yaka-cell', + headerClasses: 'color-yaka-cell' + }) + } + removeColumnsInPlace(targetColumns, ['color_code', 'yaka_kodu']) + return targetColumns +} + +;[ + columns, + generalColumns, + orderAnalysisColumns, + orderGroupColumns, + orderProductCustomerColumns, + orderMarketDetailColumns, + idleColumns, + salesBreakdownColumns +].forEach(ensureColorYakaColumn) + ;[ columns, generalColumns, @@ -2725,6 +2773,9 @@ function sortValueForColumnName (row, columnName) { function makeGroupRow (key, level, groupDef, value, groupRows) { const imageSource = groupRows.find(row => row?.product_code) || {} const aggregate = aggregateGroupFields(groupRows, groupDef.key) + const colorSummary = groupDef.key === 'color_code' ? value : distinctSummary(groupRows, 'color_code') + const yakaSummary = groupDef.key === 'yaka_kodu' ? value : distinctSummary(groupRows, 'yaka_kodu') + const colorDescriptionSummary = distinctSummary(groupRows, 'color_description') return { __group: true, row_key: `group|${key}`, @@ -2732,14 +2783,16 @@ function makeGroupRow (key, level, groupDef, value, groupRows) { level, group_field: groupDef.key, group_value: value, - label: value, + label: colorYakaGroupLabel(groupDef.key, value, colorSummary, yakaSummary), count: groupRows.length, image_product_code: imageSource.product_code || '', image_color_code: imageSource.color_code || '', + image_color_description: imageSource.color_description || '', image_yaka_kodu: imageSource.yaka_kodu || '', product_code: groupDef.key === 'product_code' ? value : distinctSummary(groupRows, 'product_code'), - color_code: groupDef.key === 'color_code' ? value : distinctSummary(groupRows, 'color_code'), - yaka_kodu: groupDef.key === 'yaka_kodu' ? value : distinctSummary(groupRows, 'yaka_kodu'), + color_code: colorSummary, + color_description: colorDescriptionSummary, + yaka_kodu: yakaSummary, item_description: distinctSummary(groupRows, 'item_description'), kategori: groupDef.key === 'kategori' ? value : distinctSummary(groupRows, 'kategori'), urun_ilk_grubu: groupDef.key === 'urun_ilk_grubu' ? value : distinctSummary(groupRows, 'urun_ilk_grubu'), @@ -3170,7 +3223,17 @@ function expandSelectedProductGroups () { } function groupLabelColumnName (row) { - return row?.group_field || 'product_code' + const field = String(row?.group_field || '') + if (field === 'color_code' || field === 'yaka_kodu') return 'color_yaka' + return field || 'product_code' +} + +function groupDisplayLabel (row) { + const field = String(row?.group_field || '') + if (field === 'color_code' || field === 'yaka_kodu') { + return colorYakaValue(row) || row?.label || row?.group_value || '' + } + return row?.label || row?.group_value || '' } function groupIndentPx (row) { @@ -3209,6 +3272,7 @@ function formatGroupCell (row, colOrName) { const name = colOrName?.name || colOrName if (name === 'image') return '' if (name === 'recommendation') return row.recommendation || '' + if (name === 'color_yaka' && ['color_code', 'yaka_kodu'].includes(String(row?.group_field || ''))) return colorYakaValue(row) if (groupDimensionFields.has(name) && name !== row?.group_field) return '' return formatTableCell(row, colOrName) } @@ -3347,6 +3411,42 @@ function columnFilterLabel (row, col) { return formattedColumnCellValue(row, col) || columnFilterValue(row, col) } +function colorYakaValue (row) { + const groupField = String(row?.group_field || '') + const groupValue = String(row?.group_value || row?.label || '').trim() + const color = String(row?.image_color_code || row?.color_code || '').trim() + const colorDescription = String(row?.image_color_description || row?.color_description || '').trim() + const yaka = String(row?.image_yaka_kodu || row?.yaka_kodu || '').trim() + const colorLabel = colorDisplayLabel(groupField === 'color_code' ? groupValue || color : color, colorDescription) + if (row?.__group && groupField === 'color_code') return colorLabel + if (row?.__group && groupField === 'yaka_kodu') { + const parentColor = colorLabel && colorLabel !== '-' ? colorLabel : '' + const groupYaka = groupValue && groupValue !== '-' ? groupValue : yaka + return [parentColor, groupYaka].filter(Boolean).join('/') + } + return [colorLabel, yaka].filter(value => value && value !== '-').join('/') +} + +function colorDisplayLabel (code, description) { + const colorCode = String(code || '').trim() + const desc = String(description || '').trim() + if (!colorCode) return '' + if (!desc) return colorCode + return `${colorCode}-${desc.toLocaleUpperCase('tr-TR')}` +} + +function colorYakaGroupLabel (groupField, groupValue, colorSummary, yakaSummary) { + const field = String(groupField || '') + const value = String(groupValue || '').trim() + if (field === 'color_code') return value + if (field === 'yaka_kodu') { + const color = String(colorSummary || '').trim() + const yaka = value || String(yakaSummary || '').trim() + return [color, yaka].filter(part => part && part !== '-').join('/') + } + return value +} + function productCategoryValue (row) { return row?.kategori || '' } @@ -3380,6 +3480,7 @@ function cleanProductFirstGroupValue (value) { function rawProductCellValue (row, name) { switch (name) { + case 'color_yaka': return colorYakaValue(row) case 'kategori': return productCategoryValue(row) case 'urun_ilk_grubu': return productFirstGroupValue(row) case 'askili_yan': return cleanOptionalGroupValue(row?.askili_yan) @@ -3393,6 +3494,7 @@ function rawProductCellValue (row, name) { function rawColumnCellValue (row, col) { const name = col?.name || col switch (name) { + case 'color_yaka': return colorYakaValue(row) case 'kategori': return productCategoryValue(row) case 'urun_ilk_grubu': return productFirstGroupValue(row) case 'askili_yan': return cleanOptionalGroupValue(row?.askili_yan) @@ -3469,6 +3571,7 @@ function formatTableCell (row, colOrName) { const col = typeof colOrName === 'object' ? colOrName : null const name = col?.name || colOrName if (name === 'image') return '' + if (name === 'color_yaka') return colorYakaValue(row) if (['kategori', 'urun_ilk_grubu', 'urun_ana_grubu'].includes(name)) return rawProductCellValue(row, name) || '' if (name === 'market_key') return displayMarketName(row.market_key) if (name === 'performance_bucket') return bucketLabel(row.performance_bucket) @@ -4042,7 +4145,7 @@ async function reload () { try { const salesBreakdownRequests = salesBreakdownTabKeys.map(tabKey => { return timedProductPerformanceGet(`sales-breakdown:${tabKey}`, '/pricing/product-performance/sales-breakdown', { - params: { limit: 800, mode: salesBreakdownModes[tabKey] }, + params: { limit: reportFetchLimit, mode: salesBreakdownModes[tabKey] }, timeout: 60000 }) }) @@ -4055,11 +4158,11 @@ async function reload () { }, timeout: 60000 }), - timedProductPerformanceGet('general', '/pricing/product-performance/general', { params: { limit: 500 }, timeout: 60000 }), - timedProductPerformanceGet('markets', '/pricing/product-performance/markets', { params: { limit: 500 }, timeout: 60000 }), - timedProductPerformanceGet('countries', '/pricing/product-performance/countries', { params: { limit: 500 }, timeout: 60000 }), + timedProductPerformanceGet('general', '/pricing/product-performance/general', { params: { limit: reportFetchLimit }, timeout: 60000 }), + timedProductPerformanceGet('markets', '/pricing/product-performance/markets', { params: { limit: reportFetchLimit }, timeout: 60000 }), + timedProductPerformanceGet('countries', '/pricing/product-performance/countries', { params: { limit: reportFetchLimit }, timeout: 60000 }), timedProductPerformanceGet('customers', '/pricing/product-performance/customers', { - params: { limit: 500, breakdown: customerBreakdown.value }, + params: { limit: reportFetchLimit, breakdown: customerBreakdown.value }, timeout: 60000 }), ...salesBreakdownRequests @@ -4101,7 +4204,7 @@ async function reload () { async function loadOrderProductCustomers (showLoading = true) { if (showLoading) loading.value = true try { - const resp = await timedProductPerformanceGet('orders:product-customers', '/pricing/product-performance/orders/product-customers', { params: { limit: 500 }, timeout: 90000 }) + const resp = await timedProductPerformanceGet('orders:product-customers', '/pricing/product-performance/orders/product-customers', { params: { limit: reportFetchLimit }, timeout: 90000 }) orderProductCustomerRows.value = (Array.isArray(resp?.data) ? resp.data : []).map(normalizeOrderProductCustomerRow) orderProductCustomersLoaded.value = true } catch (err) { @@ -4114,7 +4217,7 @@ async function loadOrderProductCustomers (showLoading = true) { async function loadOrderMarketDetails (showLoading = true) { if (showLoading) loading.value = true try { - const resp = await timedProductPerformanceGet('orders:market-details', '/pricing/product-performance/orders/market-details', { params: { limit: 800 }, timeout: 90000 }) + const resp = await timedProductPerformanceGet('orders:market-details', '/pricing/product-performance/orders/market-details', { params: { limit: reportFetchLimit }, timeout: 90000 }) orderMarketDetailRows.value = (Array.isArray(resp?.data) ? resp.data : []).map(normalizeOrderMarketDetailRow) orderMarketDetailsLoaded.value = true } catch (err) { @@ -4128,9 +4231,9 @@ async function loadOrderAnalysis (showLoading = true) { if (showLoading) loading.value = true try { const [productResp, marketResp, customerResp] = await Promise.all([ - timedProductPerformanceGet('orders:products', '/pricing/product-performance/orders', { params: { limit: 500 }, timeout: 90000 }), - timedProductPerformanceGet('orders:groups:market', '/pricing/product-performance/orders/groups', { params: { limit: 500, breakdown: 'market' }, timeout: 90000 }), - timedProductPerformanceGet('orders:groups:customer', '/pricing/product-performance/orders/groups', { params: { limit: 500, breakdown: 'customer' }, timeout: 90000 }) + timedProductPerformanceGet('orders:products', '/pricing/product-performance/orders', { params: { limit: reportFetchLimit }, timeout: 90000 }), + timedProductPerformanceGet('orders:groups:market', '/pricing/product-performance/orders/groups', { params: { limit: reportFetchLimit, breakdown: 'market' }, timeout: 90000 }), + timedProductPerformanceGet('orders:groups:customer', '/pricing/product-performance/orders/groups', { params: { limit: reportFetchLimit, breakdown: 'customer' }, timeout: 90000 }) ]) orderAnalysisRows.value = (Array.isArray(productResp?.data) ? productResp.data : []).map(normalizeOrderAnalysisRow) orderMarketRows.value = (Array.isArray(marketResp?.data) ? marketResp.data : []).map(normalizeOrderGroupRow) @@ -4417,18 +4520,26 @@ onMounted(reload) .performance-table :deep(.q-table tbody td .q-field), .performance-table :deep(.q-table tbody td input), .performance-table :deep(.q-table tbody td textarea) { - min-width: 72px; + min-width: 54px; max-width: 100%; } .performance-table :deep(.q-table tbody td .q-field__control), .performance-table :deep(.q-table tbody td .q-field__native), .performance-table :deep(.q-table tbody td .q-field__input) { - min-height: 26px; - font-size: 11px; + min-height: 24px; + font-size: 10px; line-height: 1.2; } +.performance-table :deep(.q-table th.color-yaka-cell), +.performance-table :deep(.q-table td.color-yaka-cell) { + width: 132px; + min-width: 132px; + max-width: 150px; + font-size: 10px; +} + .performance-table :deep(.q-table th:first-child), .performance-table :deep(.q-table td:first-child) { border-left: 1px solid #d5dce5; @@ -4778,39 +4889,39 @@ onMounted(reload) } .performance-table :deep(.q-table tbody tr.group-row.group-row-level-0 td) { - background: #2f5f8f; + background: #123b63; color: #fff; - border-color: #244b72; + border-color: #0b2845; } .performance-table :deep(.q-table tbody tr.group-row.group-row-level-1 td) { - background: #4f7fad; + background: #1f6f54; color: #fff; - border-color: #426d96; + border-color: #18563f; } .performance-table :deep(.q-table tbody tr.group-row.group-row-level-2 td) { - background: #d8e6f4; + background: #ffe2a8; color: #1f2937; - border-color: #b8cce0; + border-color: #d7a84d; } .performance-table :deep(.q-table tbody tr.group-row.group-row-level-3 td) { - background: #e6eef7; + background: #d8f0dc; color: #1f2937; - border-color: #c4d2e2; + border-color: #9bc7a3; } .performance-table :deep(.q-table tbody tr.group-row.group-row-level-4 td) { - background: #eef4fa; + background: #e7ecff; color: #1f2937; - border-color: #cbd8e6; + border-color: #b8c2ef; } .performance-table :deep(.q-table tbody tr.group-row.group-row-level-5 td) { - background: #f4f8fc; + background: #f2f3f5; color: #1f2937; - border-color: #d5dce5; + border-color: #c4c9d1; } .performance-table :deep(.q-table tbody tr.group-row td span),