diff --git a/svc/queries/product_performance.go b/svc/queries/product_performance.go index e832ebe..dc76216 100644 --- a/svc/queries/product_performance.go +++ b/svc/queries/product_performance.go @@ -1901,6 +1901,7 @@ func applyProductPerformanceProductRowScores(rows []models.ProductPerformanceRow rows[i].StockTurnoverTotal, float64(rows[i].MarketCountTotal), float64(rows[i].CustomerCountTotal), + productPerformanceProductRowTotalPeriodDays(rows[i]), ) rows[i].PerformanceScore = rows[i].PerformanceScore90 } @@ -1949,6 +1950,10 @@ func productPerformanceProductRowAverages(rows []models.ProductPerformanceRow) p return out } +func productPerformanceProductRowTotalPeriodDays(row models.ProductPerformanceRow) float64 { + return productPerformancePeriodDays(map[string]any{"kpi_date": row.KpiDate}, "total") +} + func productPerformanceMarginFromSales(salesUSD, salesQty, unitCost float64) float64 { if salesUSD <= 0 { return 0 @@ -2286,7 +2291,7 @@ SELECT WHEN sales_qty_total > 0 AND stock_qty <= 0 AND sales_index_total >= 1 THEN 'STOKSUZ_TALEP' WHEN sales_qty_total = 0 AND stock_qty > 0 THEN 'STOK_RISKI' WHEN gross_margin_total < 0 THEN 'FIYAT_BASKISI' - WHEN sales_index_total >= 1.4 AND gross_margin_total >= 0.25 THEN 'YILDIZ_URUN' + WHEN sales_index_total >= 1.4 AND gross_margin_total >= 0.45 THEN 'YILDIZ_URUN' WHEN gross_margin_total >= 0.35 AND sales_index_total < 0.8 THEN 'FIYAT_FIRSATI' ELSE 'TAKIP' END AS performance_bucket, @@ -2294,7 +2299,7 @@ SELECT WHEN sales_qty_total > 0 AND stock_qty <= 0 THEN 'Talep var, stok yok. Uretim/satin alma onceligi ver.' WHEN sales_qty_total = 0 AND stock_qty > 0 THEN 'Satis yok, stok maliyeti tasiyor. Piyasa/fiyat aksiyonu gerekli.' WHEN gross_margin_total < 0 THEN 'Ciplak maliyet altinda satis var. Fiyat veya maliyet kontrol edilmeli.' - WHEN sales_index_total >= 1.4 AND gross_margin_total >= 0.25 THEN 'Genel donemde guclu urun. Stok ve fiyat korunmali.' + WHEN sales_index_total >= 1.4 AND gross_margin_total >= 0.45 THEN 'Genel donemde guclu urun. Stok ve fiyat korunmali.' WHEN gross_margin_total >= 0.35 AND sales_index_total < 0.8 THEN 'Karli ama yavas. Dogru piyasada satis firsati var.' ELSE 'Izleme ve piyasa bazli aksiyon.' END AS recommendation, @@ -3581,6 +3586,19 @@ StockTotalStart AS ( WHERE stock_date <= DATE '2022-01-01' ORDER BY product_code, color_code, yaka_kodu, stock_date DESC ), +FirstSaleByProduct AS ( + SELECT + product_code, + color_code, + yaka_kodu, + MIN(sales_date) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days') AS first_sale_date_90d, + MIN(sales_date) FILTER (WHERE sales_date >= current_date - INTERVAL '179 days') AS first_sale_date_180d, + MIN(sales_date) FILTER (WHERE sales_date >= current_date - INTERVAL '359 days') AS first_sale_date_365d, + MIN(sales_date) FILTER (WHERE sales_date >= DATE '2022-01-01') AS first_sale_date_total + FROM mk_product_performance_sales_daily + WHERE sales_date >= DATE '2022-01-01' + GROUP BY product_code, color_code, yaka_kodu +), Agg AS ( SELECT $2::text AS breakdown, @@ -3683,18 +3701,55 @@ Agg AS ( COALESCE(pd.base_price_usd,0) AS base_price_usd, COALESCE(pd.cost_price_usd,0) AS cost_price_usd, COALESCE(st.stock_qty,0) AS stock_qty, - (COALESCE(s90.stock_qty,0) + COALESCE(st.stock_qty,0)) / 2.0 AS avg_stock_90d, - (COALESCE(s180.stock_qty,0) + COALESCE(st.stock_qty,0)) / 2.0 AS avg_stock_180d, - (COALESCE(s365.stock_qty,0) + COALESCE(st.stock_qty,0)) / 2.0 AS avg_stock_365d, - (COALESCE(stotal.stock_qty,0) + COALESCE(st.stock_qty,0)) / 2.0 AS avg_stock_total + (COALESCE(s90.stock_qty, st.stock_qty, 0) + COALESCE(st.stock_qty,0)) / 2.0 AS avg_stock_90d, + (COALESCE(s180.stock_qty, st.stock_qty, 0) + COALESCE(st.stock_qty,0)) / 2.0 AS avg_stock_180d, + (COALESCE(s365.stock_qty, st.stock_qty, 0) + COALESCE(st.stock_qty,0)) / 2.0 AS avg_stock_365d, + (COALESCE(stotal.stock_qty, st.stock_qty, 0) + COALESCE(st.stock_qty,0)) / 2.0 AS avg_stock_total FROM mk_product_performance_sales_daily s LEFT JOIN mk_product_performance_price_dim pd ON pd.product_code = s.product_code LEFT JOIN LatestKPI k ON k.product_code = s.product_code AND k.color_code = s.color_code AND k.yaka_kodu = s.yaka_kodu + LEFT JOIN FirstSaleByProduct fs ON fs.product_code = s.product_code AND fs.color_code = s.color_code AND fs.yaka_kodu = s.yaka_kodu LEFT JOIN StockAgg st ON st.product_code = s.product_code AND st.color_code = s.color_code AND st.yaka_kodu = s.yaka_kodu - LEFT JOIN Stock90Start s90 ON s90.product_code = s.product_code AND s90.color_code = s.color_code AND s90.yaka_kodu = s.yaka_kodu - 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 LATERAL ( + SELECT x.stock_qty + FROM mk_product_performance_stock_daily x + WHERE x.product_code = s.product_code + AND x.color_code = s.color_code + AND x.yaka_kodu = s.yaka_kodu + AND x.stock_date <= COALESCE(fs.first_sale_date_90d, current_date - INTERVAL '89 days') + ORDER BY x.stock_date DESC + LIMIT 1 + ) s90 ON TRUE + LEFT JOIN LATERAL ( + SELECT x.stock_qty + FROM mk_product_performance_stock_daily x + WHERE x.product_code = s.product_code + AND x.color_code = s.color_code + AND x.yaka_kodu = s.yaka_kodu + AND x.stock_date <= COALESCE(fs.first_sale_date_180d, current_date - INTERVAL '179 days') + ORDER BY x.stock_date DESC + LIMIT 1 + ) s180 ON TRUE + LEFT JOIN LATERAL ( + SELECT x.stock_qty + FROM mk_product_performance_stock_daily x + WHERE x.product_code = s.product_code + AND x.color_code = s.color_code + AND x.yaka_kodu = s.yaka_kodu + AND x.stock_date <= COALESCE(fs.first_sale_date_365d, current_date - INTERVAL '359 days') + ORDER BY x.stock_date DESC + LIMIT 1 + ) s365 ON TRUE + LEFT JOIN LATERAL ( + SELECT x.stock_qty + FROM mk_product_performance_stock_daily x + WHERE x.product_code = s.product_code + AND x.color_code = s.color_code + AND x.yaka_kodu = s.yaka_kodu + AND x.stock_date <= COALESCE(fs.first_sale_date_total, DATE '2022-01-01') + ORDER BY x.stock_date DESC + LIMIT 1 + ) stotal ON TRUE ) s WHERE sales_date >= DATE '2022-01-01' AND upper(translate(btrim(COALESCE(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') @@ -3813,7 +3868,7 @@ SELECT CASE WHEN NOT has_cost THEN 0 WHEN sales_usd_90d <= 0 THEN 1 ELSE ROUND(( LEAST(35, sales_usd_90d / 25000 * 35) - + LEAST(30, GREATEST(gross_margin_cost_90d,0) / 0.40 * 30) + + LEAST(30, GREATEST(gross_margin_cost_90d,0) / 0.45 * 30) + LEAST(20, product_group_count_90d / 8.0 * 20) + LEAST(15, sales_qty_90d / 500 * 15) )::numeric, 4) @@ -3821,7 +3876,7 @@ SELECT CASE WHEN NOT has_cost THEN 0 WHEN sales_usd_180d <= 0 THEN 1 ELSE ROUND(( LEAST(35, sales_usd_180d / 50000 * 35) - + LEAST(30, GREATEST(gross_margin_cost_180d,0) / 0.40 * 30) + + LEAST(30, GREATEST(gross_margin_cost_180d,0) / 0.45 * 30) + LEAST(20, product_group_count_180d / 8.0 * 20) + LEAST(15, sales_qty_180d / 1000 * 15) )::numeric, 4) @@ -3829,7 +3884,7 @@ SELECT CASE WHEN NOT has_cost THEN 0 WHEN sales_usd_365d <= 0 THEN 1 ELSE ROUND(( LEAST(35, sales_usd_365d / 100000 * 35) - + LEAST(30, GREATEST(gross_margin_cost_365d,0) / 0.40 * 30) + + LEAST(30, GREATEST(gross_margin_cost_365d,0) / 0.45 * 30) + LEAST(20, product_group_count_365d / 8.0 * 20) + LEAST(15, sales_qty_365d / 2000 * 15) )::numeric, 4) @@ -3837,18 +3892,18 @@ SELECT CASE WHEN NOT has_cost THEN 0 WHEN sales_usd_total <= 0 THEN 1 ELSE ROUND(( LEAST(35, sales_usd_total / 300000 * 35) - + LEAST(30, GREATEST(gross_margin_cost_total,0) / 0.40 * 30) + + LEAST(30, GREATEST(gross_margin_cost_total,0) / 0.45 * 30) + LEAST(20, product_group_count_total / 8.0 * 20) + LEAST(15, sales_qty_total / 6000 * 15) )::numeric, 4) END AS customer_score_total, CASE WHEN NOT has_cost THEN 0 WHEN sales_usd_90d <= 0 THEN 1 ELSE ROUND( - LEAST(30, GREATEST(gross_margin_cost_90d,0) / 0.40 * 30) + LEAST(30, GREATEST(gross_margin_cost_90d,0) / 0.45 * 30) + LEAST(20, (CASE WHEN avg_stock_90d > 0 THEN sales_qty_90d / NULLIF(avg_stock_90d,0) * 4.0 ELSE 0 END) / 4.0 * 20) - + LEAST(20, sales_usd_90d / 10000 * 20) - + LEAST(15, market_count_90d / 8.0 * 15) - + LEAST(15, customer_count_90d / 25.0 * 15), + + LEAST(20, sales_usd_90d / 25000 * 20) + + LEAST(5, market_count_90d / 3.0 * 5) + + LEAST(25, customer_count_90d / 8.0 * 25), 4 ) END AS performance_score, @@ -6249,6 +6304,7 @@ func productPerformanceSalesPeriodScore(row map[string]any, suffix string) float stockTurnover, productPerformancePeriodCount(row, "market_count", suffix), productPerformancePeriodCount(row, "customer_count", suffix), + productPerformancePeriodDays(row, suffix), ) } @@ -6292,16 +6348,17 @@ func productPerformanceCustomerSalesPeriodScore(row map[string]any) float64 { return productPerformanceCustomerScore(suffix, salesUSD, margin, productCount, salesQty) } -func productPerformanceProductScore(suffix string, salesUSD, salesIndex, margin, stockTurnover, marketCount, customerCount float64) float64 { +func productPerformanceProductScore(suffix string, salesUSD, salesIndex, margin, stockTurnover, marketCount, customerCount float64, periodDays ...float64) float64 { if salesUSD <= 0 { return 1 } - revenueScore := productPerformanceRevenueScore(suffix, salesUSD, salesIndex, productPerformanceProductRevenueTarget(suffix)) + days := productPerformanceScorePeriodDays(suffix, periodDays...) + revenueScore := productPerformanceRevenueScore(suffix, salesUSD, salesIndex, productPerformanceProductRevenueTargetForDays(suffix, days)) score := 0.30*productPerformanceMarginComponentScore(margin) + 0.20*productPerformanceRatioScore(stockTurnover, productPerformanceStockTurnoverTarget(suffix)) + 0.20*revenueScore + 0.05*productPerformanceRatioScore(marketCount, productPerformanceMarketSpreadTarget(suffix)) + - 0.25*productPerformanceRatioScore(customerCount, productPerformanceCustomerSpreadTarget(suffix)) + 0.25*productPerformanceRatioScore(customerCount, productPerformanceCustomerSpreadTargetForDays(suffix, days)) return productPerformanceRoundScore(score) } @@ -6323,6 +6380,10 @@ func productPerformanceMarketSpreadTarget(suffix string) float64 { } func productPerformanceCustomerSpreadTarget(suffix string) float64 { + return productPerformanceCustomerSpreadTargetForDays(suffix, productPerformanceScorePeriodDays(suffix)) +} + +func productPerformanceCustomerSpreadTargetForDays(suffix string, periodDays float64) float64 { switch suffix { case "90d": return 8 @@ -6331,7 +6392,7 @@ func productPerformanceCustomerSpreadTarget(suffix string) float64 { case "365d": return 50 default: - return 50 + return 20 * productPerformanceTotalPeriodMultiplier(periodDays) } } @@ -6358,7 +6419,7 @@ func productPerformanceRelativeIndex(value, average float64) float64 { } func productPerformanceMarginComponentScore(margin float64) float64 { - return productPerformanceRatioScore(maxFloat(0, margin), 0.40) + return productPerformanceRatioScore(maxFloat(0, margin), 0.45) } func productPerformanceRatioScore(value, target float64) float64 { @@ -6379,18 +6440,49 @@ func productPerformanceRoundScore(score float64) float64 { } func productPerformanceProductRevenueTarget(suffix string) float64 { + return productPerformanceProductRevenueTargetForDays(suffix, productPerformanceScorePeriodDays(suffix)) +} + +func productPerformanceProductRevenueTargetForDays(suffix string, periodDays float64) float64 { switch suffix { case "180d": - return 20000 + return 50000 case "365d": - return 40000 + return 100000 case "total": - return 120000 + return 50000 * productPerformanceTotalPeriodMultiplier(periodDays) default: - return 10000 + return 25000 } } +func productPerformanceScorePeriodDays(suffix string, periodDays ...float64) float64 { + if len(periodDays) > 0 && periodDays[0] > 0 { + return periodDays[0] + } + switch suffix { + case "90d": + return 90 + case "180d": + return 180 + case "365d": + return 360 + case "total": + return productPerformancePeriodDays(map[string]any{ + "kpi_date": time.Now().Format("2006-01-02"), + }, "total") + default: + return 0 + } +} + +func productPerformanceTotalPeriodMultiplier(periodDays float64) float64 { + if periodDays <= 0 { + periodDays = 180 + } + return maxFloat(1, periodDays/180.0) +} + func productPerformanceCustomerRevenueTarget(suffix string) float64 { switch suffix { case "180d": diff --git a/svc/queries/product_performance_sql.go b/svc/queries/product_performance_sql.go index 00f125c..897f917 100644 --- a/svc/queries/product_performance_sql.go +++ b/svc/queries/product_performance_sql.go @@ -489,6 +489,10 @@ SalesAgg AS ( COUNT(DISTINCT NULLIF(customer_code, '-')) FILTER (WHERE sales_date >= $1::date - INTERVAL '179 days' AND COALESCE(sales_usd, 0) > 0) AS customer_count_180d, COUNT(DISTINCT NULLIF(customer_code, '-')) FILTER (WHERE sales_date >= $1::date - INTERVAL '359 days' AND COALESCE(sales_usd, 0) > 0) AS customer_count_365d, COUNT(DISTINCT NULLIF(customer_code, '-')) FILTER (WHERE sales_date >= DATE '2022-01-01' AND COALESCE(sales_usd, 0) > 0) AS customer_count_total, + MIN(sales_date) FILTER (WHERE sales_date >= $1::date - INTERVAL '89 days') AS first_sale_date_90d, + MIN(sales_date) FILTER (WHERE sales_date >= $1::date - INTERVAL '179 days') AS first_sale_date_180d, + MIN(sales_date) FILTER (WHERE sales_date >= $1::date - INTERVAL '359 days') AS first_sale_date_365d, + MIN(sales_date) FILTER (WHERE sales_date >= DATE '2022-01-01') AS first_sale_date_total, MAX(sales_date) AS last_sale_date, MAX(last_ref_number) AS last_ref_number FROM mk_product_performance_sales_daily @@ -503,7 +507,9 @@ Scope AS ( item_description, kategori, seri, yas_grubu, askili_yan, urun_ilk_grubu, urun_ana_grubu, urun_alt_grubu, sales_qty_30d, sales_qty_90d, sales_qty_180d, sales_qty_365d, sales_qty_730d, sales_qty_total, sales_usd_30d, sales_usd_90d, sales_usd_180d, sales_usd_365d, sales_usd_total, - customer_count_90d, customer_count_180d, customer_count_365d, customer_count_total, last_sale_date, last_ref_number + customer_count_90d, customer_count_180d, customer_count_365d, customer_count_total, + first_sale_date_90d, first_sale_date_180d, first_sale_date_365d, first_sale_date_total, + last_sale_date, last_ref_number FROM SalesAgg UNION ALL SELECT @@ -534,6 +540,10 @@ Scope AS ( 0 AS customer_count_180d, 0 AS customer_count_365d, 0 AS customer_count_total, + NULL::date AS first_sale_date_90d, + NULL::date AS first_sale_date_180d, + NULL::date AS first_sale_date_365d, + NULL::date AS first_sale_date_total, NULL::date AS last_sale_date, '' AS last_ref_number FROM LatestStock ls @@ -576,10 +586,10 @@ Base AS ( COALESCE(s.sales_qty_180d, 0) / 180.0 AS avg_daily_sales_180d, COALESCE(s.sales_qty_365d, 0) / 360.0 AS avg_daily_sales_365d, COALESCE(s.sales_qty_total, 0) / GREATEST(1, ($1::date - DATE '2022-01-01') + 1) AS avg_daily_sales_total, - (COALESCE(s90.stock_qty, 0) + COALESCE(ls.stock_qty, 0)) / 2.0 AS avg_stock_90d, - (COALESCE(s180.stock_qty, 0) + COALESCE(ls.stock_qty, 0)) / 2.0 AS avg_stock_180d, - (COALESCE(s365.stock_qty, 0) + COALESCE(ls.stock_qty, 0)) / 2.0 AS avg_stock_365d, - (COALESCE(stotal.stock_qty, 0) + COALESCE(ls.stock_qty, 0)) / 2.0 AS avg_stock_total, + (COALESCE(s90.stock_qty, ls.stock_qty, 0) + COALESCE(ls.stock_qty, 0)) / 2.0 AS avg_stock_90d, + (COALESCE(s180.stock_qty, ls.stock_qty, 0) + COALESCE(ls.stock_qty, 0)) / 2.0 AS avg_stock_180d, + (COALESCE(s365.stock_qty, ls.stock_qty, 0) + COALESCE(ls.stock_qty, 0)) / 2.0 AS avg_stock_365d, + (COALESCE(stotal.stock_qty, ls.stock_qty, 0) + COALESCE(ls.stock_qty, 0)) / 2.0 AS avg_stock_total, CASE WHEN COALESCE(s.sales_qty_90d, 0) = 0 THEN 0 ELSE COALESCE(s.sales_usd_90d, 0) / NULLIF(s.sales_qty_90d, 0) END AS avg_price_usd_90d, CASE WHEN COALESCE(s.sales_qty_180d, 0) = 0 THEN 0 ELSE COALESCE(s.sales_usd_180d, 0) / NULLIF(s.sales_qty_180d, 0) END AS avg_price_usd_180d, COALESCE(s.sales_usd_90d, 0) - (COALESCE(s.sales_qty_90d, 0) * COALESCE(pd.cost_price_usd, 0)) AS gross_profit_usd_90d, @@ -596,11 +606,47 @@ Base AS ( CASE WHEN COALESCE(s.sales_qty_180d, 0) = 0 THEN 0 ELSE (COALESCE(s.sales_usd_180d, 0) / NULLIF(s.sales_qty_180d, 0)) - COALESCE(pd.base_price_usd, 0) END AS unit_profit_base_180d FROM Scope s LEFT JOIN LatestStock ls ON ls.product_code=s.product_code AND ls.color_code=s.color_code AND ls.yaka_kodu=s.yaka_kodu - LEFT JOIN Stock90Start s90 ON s90.product_code=s.product_code AND s90.color_code=s.color_code AND s90.yaka_kodu=s.yaka_kodu - 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 PriceDim pd ON pd.product_code=s.product_code + LEFT JOIN LATERAL ( + SELECT x.stock_qty + FROM mk_product_performance_stock_daily x + WHERE x.product_code=s.product_code + AND x.color_code=s.color_code + AND x.yaka_kodu=s.yaka_kodu + AND x.stock_date <= COALESCE(s.first_sale_date_90d, $1::date - INTERVAL '89 days') + ORDER BY x.stock_date DESC + LIMIT 1 + ) s90 ON TRUE + LEFT JOIN LATERAL ( + SELECT x.stock_qty + FROM mk_product_performance_stock_daily x + WHERE x.product_code=s.product_code + AND x.color_code=s.color_code + AND x.yaka_kodu=s.yaka_kodu + AND x.stock_date <= COALESCE(s.first_sale_date_180d, $1::date - INTERVAL '179 days') + ORDER BY x.stock_date DESC + LIMIT 1 + ) s180 ON TRUE + LEFT JOIN LATERAL ( + SELECT x.stock_qty + FROM mk_product_performance_stock_daily x + WHERE x.product_code=s.product_code + AND x.color_code=s.color_code + AND x.yaka_kodu=s.yaka_kodu + AND x.stock_date <= COALESCE(s.first_sale_date_365d, $1::date - INTERVAL '359 days') + ORDER BY x.stock_date DESC + LIMIT 1 + ) s365 ON TRUE + LEFT JOIN LATERAL ( + SELECT x.stock_qty + FROM mk_product_performance_stock_daily x + WHERE x.product_code=s.product_code + AND x.color_code=s.color_code + AND x.yaka_kodu=s.yaka_kodu + AND x.stock_date <= COALESCE(s.first_sale_date_total, DATE '2022-01-01') + ORDER BY x.stock_date DESC + LIMIT 1 + ) stotal ON TRUE WHERE upper(translate(btrim(COALESCE(NULLIF(s.urun_ilk_grubu,''), pd.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') ), Spread AS ( @@ -683,15 +729,15 @@ SELECT COALESCE(sales_index_90d, 0), COALESCE(sales_index_180d, 0), COALESCE(sales_index_365d, 0), COALESCE(sales_index_total, 0), COALESCE(price_index_90d, 0), COALESCE(margin_index_90d, 0), CASE WHEN COALESCE(sales_usd_90d, 0) <= 0 THEN 1 ELSE ROUND( - LEAST(35, COALESCE(sales_index_90d, 0) * 18) - + LEAST(25, GREATEST(COALESCE(gross_margin_90d, 0), 0) * 50) - + LEAST(20, COALESCE(customer_count_90d, 0) * 2) - + LEAST(10, COALESCE(market_count_90d, 0) * 3) + LEAST(30, GREATEST(COALESCE(gross_margin_90d, 0), 0) / 0.45 * 30) + LEAST(20, COALESCE(stock_turnover_90d, 0) / 4.0 * 20) + + LEAST(20, COALESCE(sales_usd_90d, 0) / 25000.0 * 20) + + LEAST(5, COALESCE(market_count_90d, 0) / 3.0 * 5) + + LEAST(25, COALESCE(customer_count_90d, 0) / 8.0 * 25) , 4) END AS performance_score, CASE - WHEN COALESCE(sales_index_90d,0) >= 1.25 AND COALESCE(gross_margin_90d,0) >= 0.25 THEN 'YILDIZ_URUN' + WHEN COALESCE(sales_index_90d,0) >= 1.25 AND COALESCE(gross_margin_90d,0) >= 0.45 THEN 'YILDIZ_URUN' WHEN COALESCE(stock_qty,0) <= 0 AND COALESCE(sales_qty_90d,0) > 0 THEN 'STOKSUZ_TALEP' WHEN COALESCE(stock_days_90d,0) > 180 AND COALESCE(sales_index_90d,0) < 0.75 THEN 'STOK_RISKI' WHEN COALESCE(price_index_90d,0) > 1.10 AND COALESCE(sales_index_90d,0) < 0.80 THEN 'FIYAT_BASKISI' @@ -699,7 +745,7 @@ SELECT ELSE 'TAKIP' END AS performance_bucket, CASE - WHEN COALESCE(sales_index_90d,0) >= 1.25 AND COALESCE(gross_margin_90d,0) >= 0.25 THEN 'Piyasa ustu satis ve iyi marj: stok ve fiyat gucu takip edilmeli.' + WHEN COALESCE(sales_index_90d,0) >= 1.25 AND COALESCE(gross_margin_90d,0) >= 0.45 THEN 'Piyasa ustu satis ve iyi marj: stok ve fiyat gucu takip edilmeli.' WHEN COALESCE(stock_qty,0) <= 0 AND COALESCE(sales_qty_90d,0) > 0 THEN 'Talep var, stok yok: uretim/satin alma planina alinmali.' WHEN COALESCE(stock_days_90d,0) > 180 AND COALESCE(sales_index_90d,0) < 0.75 THEN 'Stok yuksek, satis piyasa alti: kampanya veya fiyat kontrolu gerekli.' WHEN COALESCE(price_index_90d,0) > 1.10 AND COALESCE(sales_index_90d,0) < 0.80 THEN 'Fiyat piyasa ustunde ve satis zayif: fiyat revizyonu degerlendirilmeli.' diff --git a/svc/routes/product_performance_excel.go b/svc/routes/product_performance_excel.go index 2e41c57..114b24b 100644 --- a/svc/routes/product_performance_excel.go +++ b/svc/routes/product_performance_excel.go @@ -447,7 +447,8 @@ func productPerformanceExcelPeriodColumns(label, suffix string, values func(*pro }), numberExcelColumn(label+" Ürün Skor", "number", func(v *productPerformanceExcelVariant) any { qty, usd, markets, customers := values(v) - return productPerformanceExcelProductScore(suffix, usd, productPerformanceExcelStockTurnover(qty, productPerformanceExcelAvgStock(v, suffix), productPerformanceExcelPeriodDays(v, suffix)), float64(markets), float64(customers), productPerformanceExcelMargin(usd, qty, v.CostPriceUSD)) + days := productPerformanceExcelPeriodDays(v, suffix) + return productPerformanceExcelProductScore(suffix, usd, productPerformanceExcelStockTurnover(qty, productPerformanceExcelAvgStock(v, suffix), days), float64(markets), float64(customers), productPerformanceExcelMargin(usd, qty, v.CostPriceUSD), days) }), } } @@ -685,9 +686,11 @@ func productPerformanceExcelSortNumber(row *productPerformanceExcelVariant, sort case "customer_count_total": return float64(row.CustomerCountTotal) case "performance_score", "performance_score_90d": - return productPerformanceExcelProductScore("90d", row.SalesUSD90, productPerformanceExcelStockTurnover(row.SalesQty90, productPerformanceExcelAvgStock(row, "90d"), productPerformanceExcelPeriodDays(row, "90d")), float64(row.marketCount90()), float64(row.CustomerCount90), productPerformanceExcelMargin(row.SalesUSD90, row.SalesQty90, row.CostPriceUSD)) + days := productPerformanceExcelPeriodDays(row, "90d") + return productPerformanceExcelProductScore("90d", row.SalesUSD90, productPerformanceExcelStockTurnover(row.SalesQty90, productPerformanceExcelAvgStock(row, "90d"), days), float64(row.marketCount90()), float64(row.CustomerCount90), productPerformanceExcelMargin(row.SalesUSD90, row.SalesQty90, row.CostPriceUSD), days) case "performance_score_total": - return productPerformanceExcelProductScore("total", row.totalUSD(), productPerformanceExcelStockTurnover(row.totalQty(), productPerformanceExcelAvgStock(row, "total"), productPerformanceExcelPeriodDays(row, "total")), float64(row.marketCountTotal()), float64(row.CustomerCountTotal), productPerformanceExcelMargin(row.totalUSD(), row.totalQty(), row.CostPriceUSD)) + days := productPerformanceExcelPeriodDays(row, "total") + return productPerformanceExcelProductScore("total", row.totalUSD(), productPerformanceExcelStockTurnover(row.totalQty(), productPerformanceExcelAvgStock(row, "total"), days), float64(row.marketCountTotal()), float64(row.CustomerCountTotal), productPerformanceExcelMargin(row.totalUSD(), row.totalQty(), row.CostPriceUSD), days) default: return 0 } @@ -803,7 +806,7 @@ func productPerformanceExcelStatus(row *productPerformanceExcelVariant) string { return "Stok Riski" case productPerformanceExcelMargin(row.totalUSD(), row.totalQty(), row.CostPriceUSD) < 0: return "Fiyat Baskısı" - case productPerformanceExcelProductScore("total", row.totalUSD(), productPerformanceExcelStockTurnover(row.totalQty(), productPerformanceExcelAvgStock(row, "total"), productPerformanceExcelPeriodDays(row, "total")), float64(row.marketCountTotal()), float64(row.CustomerCountTotal), productPerformanceExcelMargin(row.totalUSD(), row.totalQty(), row.CostPriceUSD)) >= 70: + case productPerformanceExcelProductScore("total", row.totalUSD(), productPerformanceExcelStockTurnover(row.totalQty(), productPerformanceExcelAvgStock(row, "total"), productPerformanceExcelPeriodDays(row, "total")), float64(row.marketCountTotal()), float64(row.CustomerCountTotal), productPerformanceExcelMargin(row.totalUSD(), row.totalQty(), row.CostPriceUSD), productPerformanceExcelPeriodDays(row, "total")) >= 70: return "Yıldız Ürün" default: return productPerformanceExcelBucketLabel(row.PerformanceBucket) @@ -954,16 +957,17 @@ func productPerformanceExcelMargin(salesUSD, qty, unitCost float64) float64 { return productPerformanceExcelGrossProfit(salesUSD, qty, unitCost) / salesUSD } -func productPerformanceExcelProductScore(suffix string, salesUSD, stockTurnover, marketCount, customerCount, margin float64) float64 { +func productPerformanceExcelProductScore(suffix string, salesUSD, stockTurnover, marketCount, customerCount, margin float64, periodDays ...float64) float64 { if salesUSD <= 0 { return 1 } - revenueScore := productPerformanceExcelRatioScore(salesUSD, productPerformanceExcelProductRevenueTarget(suffix)) + days := productPerformanceExcelScorePeriodDays(suffix, periodDays...) + revenueScore := productPerformanceExcelRatioScore(salesUSD, productPerformanceExcelProductRevenueTargetForDays(suffix, days)) score := 0.30*productPerformanceExcelMarginScore(margin) + 0.20*productPerformanceExcelRatioScore(stockTurnover, 4) + 0.20*revenueScore + 0.05*productPerformanceExcelRatioScore(marketCount, productPerformanceExcelMarketSpreadTarget(suffix)) + - 0.25*productPerformanceExcelRatioScore(customerCount, productPerformanceExcelCustomerSpreadTarget(suffix)) + 0.25*productPerformanceExcelRatioScore(customerCount, productPerformanceExcelCustomerSpreadTargetForDays(suffix, days)) return productPerformanceExcelRoundScore(score) } @@ -981,6 +985,10 @@ func productPerformanceExcelMarketSpreadTarget(suffix string) float64 { } func productPerformanceExcelCustomerSpreadTarget(suffix string) float64 { + return productPerformanceExcelCustomerSpreadTargetForDays(suffix, productPerformanceExcelScorePeriodDays(suffix)) +} + +func productPerformanceExcelCustomerSpreadTargetForDays(suffix string, periodDays float64) float64 { switch suffix { case "90d": return 8 @@ -989,28 +997,62 @@ func productPerformanceExcelCustomerSpreadTarget(suffix string) float64 { case "365d": return 50 default: - return 50 + return 20 * productPerformanceExcelTotalPeriodMultiplier(periodDays) } } func productPerformanceExcelProductRevenueTarget(suffix string) float64 { + return productPerformanceExcelProductRevenueTargetForDays(suffix, productPerformanceExcelScorePeriodDays(suffix)) +} + +func productPerformanceExcelProductRevenueTargetForDays(suffix string, periodDays float64) float64 { switch suffix { case "180d": - return 20000 + return 50000 case "365d": - return 40000 + return 100000 case "total": - return 120000 + return 50000 * productPerformanceExcelTotalPeriodMultiplier(periodDays) default: - return 10000 + return 25000 } } +func productPerformanceExcelScorePeriodDays(suffix string, periodDays ...float64) float64 { + if len(periodDays) > 0 && periodDays[0] > 0 { + return periodDays[0] + } + switch suffix { + case "90d": + return 90 + case "180d": + return 180 + case "365d": + return 360 + case "total": + start := time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC) + now := time.Now() + if now.Before(start) { + return 0 + } + return now.Sub(start).Hours()/24 + 1 + default: + return 0 + } +} + +func productPerformanceExcelTotalPeriodMultiplier(periodDays float64) float64 { + if periodDays <= 0 { + periodDays = 180 + } + return math.Max(1, periodDays/180.0) +} + func productPerformanceExcelMarginScore(margin float64) float64 { if margin < 0 { margin = 0 } - return productPerformanceExcelRatioScore(margin, 0.40) + return productPerformanceExcelRatioScore(margin, 0.45) } func productPerformanceExcelRatioScore(value, target float64) float64 { diff --git a/ui/src/pages/ProductPerformanceProfitability.vue b/ui/src/pages/ProductPerformanceProfitability.vue index f423859..4be8959 100644 --- a/ui/src/pages/ProductPerformanceProfitability.vue +++ b/ui/src/pages/ProductPerformanceProfitability.vue @@ -3631,8 +3631,10 @@ function salesGroupPerformanceScore (row) { function productSalesPeriodScore (row) { const suffix = row?.suffix || '90d' + const periodDays = productPerformancePeriodDays(row, suffix) return productScore100({ suffix, + periodDays, salesUSD: Number(row?.sales_usd_90d || 0), salesIndex: Number(row?.sales_index_90d || 0), margin: Number(row?.gross_margin_cost_90d ?? row?.gross_margin_90d ?? 0), @@ -3698,15 +3700,15 @@ function periodMetricSource (row, suffix) { } } -function productScore100 ({ suffix = '90d', salesUSD = 0, margin = 0, stockTurnover = 0, marketCount = 0, customerCount = 0 } = {}) { +function productScore100 ({ suffix = '90d', periodDays = 0, salesUSD = 0, margin = 0, stockTurnover = 0, marketCount = 0, customerCount = 0 } = {}) { if (Number(salesUSD || 0) <= 0) return 1 - const revenue = ratioScore(salesUSD, productRevenueTarget(suffix)) + const revenue = ratioScore(salesUSD, productRevenueTarget(suffix, periodDays)) return clampScore( 0.30 * marginScore(margin) + 0.20 * ratioScore(stockTurnover, stockTurnoverTarget(suffix)) + 0.20 * revenue + 0.05 * ratioScore(marketCount, marketSpreadTarget(suffix)) + - 0.25 * ratioScore(customerCount, customerSpreadTarget(suffix)) + 0.25 * ratioScore(customerCount, customerSpreadTarget(suffix, periodDays)) ) } @@ -3728,7 +3730,7 @@ function ratioScore (value, target) { } function marginScore (margin) { - return ratioScore(Math.max(0, Number(margin || 0)), 0.40) + return ratioScore(Math.max(0, Number(margin || 0)), 0.45) } function clampScore (score) { @@ -3737,11 +3739,11 @@ function clampScore (score) { return Math.round(Math.min(100, n) * 10000) / 10000 } -function productRevenueTarget (suffix) { - if (suffix === '180d') return 20000 - if (suffix === '365d') return 40000 - if (suffix === 'total') return 120000 - return 10000 +function productRevenueTarget (suffix, periodDays = 0) { + if (suffix === '180d') return 50000 + if (suffix === '365d') return 100000 + if (suffix === 'total') return 50000 * totalPeriodMultiplier(periodDays) + return 25000 } function customerRevenueTarget (suffix) { @@ -3780,11 +3782,19 @@ function marketSpreadTarget (suffix) { return 6 } -function customerSpreadTarget (suffix) { +function customerSpreadTarget (suffix, periodDays = 0) { if (suffix === '90d') return 8 if (suffix === '180d') return 20 if (suffix === '365d') return 50 - return 50 + return 20 * totalPeriodMultiplier(periodDays) +} + +function totalPeriodMultiplier (periodDays = 0) { + let days = Number(periodDays || 0) + if (!Number.isFinite(days) || days <= 0) { + days = productPerformancePeriodDays({ kpi_date: new Date().toISOString().slice(0, 10) }, 'total') + } + return Math.max(1, days / 180) } function periodCount (row, prefix, suffix) {