From 5d4216d42e2cf57bb07d0cb75a4415050008476e Mon Sep 17 00:00:00 2001 From: M_Kececi Date: Mon, 6 Jul 2026 16:54:25 +0300 Subject: [PATCH] Fix product performance grouped JSON build --- svc/queries/product_performance.go | 55 ++++++++++++------- svc/queries/product_performance_sql.go | 8 +-- .../pages/ProductPerformanceProfitability.vue | 44 ++++++++++----- 3 files changed, 69 insertions(+), 38 deletions(-) diff --git a/svc/queries/product_performance.go b/svc/queries/product_performance.go index 1fce559..b671361 100644 --- a/svc/queries/product_performance.go +++ b/svc/queries/product_performance.go @@ -3804,10 +3804,10 @@ SELECT avg_stock_180d, avg_stock_365d, avg_stock_total, - CASE WHEN avg_stock_90d > 0 THEN sales_qty_90d / NULLIF(avg_stock_90d,0) ELSE 0 END AS stock_turnover_90d, - CASE WHEN avg_stock_180d > 0 THEN sales_qty_180d / NULLIF(avg_stock_180d,0) ELSE 0 END AS stock_turnover_180d, + CASE WHEN avg_stock_90d > 0 THEN sales_qty_90d / NULLIF(avg_stock_90d,0) * 4.0 ELSE 0 END AS stock_turnover_90d, + CASE WHEN avg_stock_180d > 0 THEN sales_qty_180d / NULLIF(avg_stock_180d,0) * 2.0 ELSE 0 END AS stock_turnover_180d, CASE WHEN avg_stock_365d > 0 THEN sales_qty_365d / NULLIF(avg_stock_365d,0) ELSE 0 END AS stock_turnover_365d, - CASE WHEN avg_stock_total > 0 THEN sales_qty_total / NULLIF(avg_stock_total,0) ELSE 0 END AS stock_turnover_total, + CASE WHEN avg_stock_total > 0 THEN sales_qty_total / NULLIF(avg_stock_total,0) * 360.0 / GREATEST(1, (current_date - DATE '2022-01-01') + 1) ELSE 0 END AS stock_turnover_total, has_cost, sales_index_90d, CASE WHEN NOT has_cost THEN 0 WHEN sales_usd_90d <= 0 THEN 1 ELSE @@ -3845,7 +3845,7 @@ SELECT 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(20, (CASE WHEN avg_stock_90d > 0 THEN sales_qty_90d / NULLIF(avg_stock_90d,0) ELSE 0 END) / 1.5 * 20) + + 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), @@ -4517,10 +4517,10 @@ SELECT ( 'stock_days_180d', stock_days_180d, 'stock_days_365d', stock_days_365d, 'stock_days_total', stock_days_total, - 'stock_turnover_90d', CASE WHEN avg_stock_90d > 0 THEN sales_qty_90d / NULLIF(avg_stock_90d,0) ELSE 0 END, - 'stock_turnover_180d', CASE WHEN avg_stock_180d > 0 THEN sales_qty_180d / NULLIF(avg_stock_180d,0) ELSE 0 END, + 'stock_turnover_90d', CASE WHEN avg_stock_90d > 0 THEN sales_qty_90d / NULLIF(avg_stock_90d,0) * 4.0 ELSE 0 END, + 'stock_turnover_180d', CASE WHEN avg_stock_180d > 0 THEN sales_qty_180d / NULLIF(avg_stock_180d,0) * 2.0 ELSE 0 END, 'stock_turnover_365d', CASE WHEN avg_stock_365d > 0 THEN sales_qty_365d / NULLIF(avg_stock_365d,0) ELSE 0 END, - 'stock_turnover_total', CASE WHEN avg_stock_total > 0 THEN sales_qty_total / NULLIF(avg_stock_total,0) ELSE 0 END + 'stock_turnover_total', CASE WHEN avg_stock_total > 0 THEN sales_qty_total / NULLIF(avg_stock_total,0) * 360.0 / GREATEST(1, ((SELECT kpi_date FROM LatestKPIDate) - DATE '2022-01-01') + 1) ELSE 0 END ) ) AS row_json FROM ( @@ -4643,10 +4643,10 @@ func queryProductPerformanceSQLLeafRows(ctx context.Context, pg *sql.DB, mode st query := productPerformanceSQLSourceCTE(mode) + fmt.Sprintf(` SELECT to_jsonb(t) || jsonb_build_object( 'row_key', 'leaf|' || product_code || '|' || color_code || '|' || yaka_kodu || '|' || market_key, - 'stock_turnover_90d', CASE WHEN avg_stock_90d > 0 THEN sales_qty_90d / NULLIF(avg_stock_90d,0) ELSE 0 END, - 'stock_turnover_180d', CASE WHEN avg_stock_180d > 0 THEN sales_qty_180d / NULLIF(avg_stock_180d,0) ELSE 0 END, + 'stock_turnover_90d', CASE WHEN avg_stock_90d > 0 THEN sales_qty_90d / NULLIF(avg_stock_90d,0) * 4.0 ELSE 0 END, + 'stock_turnover_180d', CASE WHEN avg_stock_180d > 0 THEN sales_qty_180d / NULLIF(avg_stock_180d,0) * 2.0 ELSE 0 END, 'stock_turnover_365d', CASE WHEN avg_stock_365d > 0 THEN sales_qty_365d / NULLIF(avg_stock_365d,0) ELSE 0 END, - 'stock_turnover_total', CASE WHEN avg_stock_total > 0 THEN sales_qty_total / NULLIF(avg_stock_total,0) ELSE 0 END + 'stock_turnover_total', CASE WHEN avg_stock_total > 0 THEN sales_qty_total / NULLIF(avg_stock_total,0) * 360.0 / GREATEST(1, ((SELECT kpi_date FROM LatestKPIDate) - DATE '2022-01-01') + 1) ELSE 0 END ) AS row_json FROM Source t %s @@ -6000,11 +6000,7 @@ func deriveProductPerformanceGroupMetrics(out map[string]any, groupField string) } else if _, ok := out["stock_days_"+suffix]; !ok { out["stock_days_"+suffix] = 0 } - if turnoverBase > 0 { - out["stock_turnover_"+suffix] = qty / turnoverBase - } else { - out["stock_turnover_"+suffix] = 0 - } + out["stock_turnover_"+suffix] = productPerformanceAnnualizedStockTurnover(qty, turnoverBase, days) if qty > 0 { out["avg_price_usd_"+suffix] = sales / qty } @@ -6111,7 +6107,7 @@ func productPerformancePeriodDays(row map[string]any, suffix string) float64 { case "180d": return 180 case "365d": - return 365 + return 360 case "total": start := parseProductPerformanceDate(stringFromMap(row, "period_start")) if start.IsZero() { @@ -6130,6 +6126,19 @@ func productPerformancePeriodDays(row map[string]any, suffix string) float64 { } } +const productPerformanceStockTurnoverYearDays = 360.0 + +func productPerformanceAnnualizedStockTurnover(salesQty, avgStock, periodDays float64) float64 { + if salesQty <= 0 || avgStock <= 0 { + return 0 + } + raw := salesQty / avgStock + if periodDays <= 0 { + return raw + } + return raw * productPerformanceStockTurnoverYearDays / periodDays +} + func parseProductPerformanceDate(value string) time.Time { value = strings.TrimSpace(value) if value == "" { @@ -6206,8 +6215,12 @@ func productPerformanceSalesPeriodScore(row map[string]any, suffix string) float salesQty := floatFromMap(row, "sales_qty_"+suffix) stockTurnover := floatFromMap(row, "stock_turnover_"+suffix) if stockTurnover == 0 { - if stockQty := floatFromMap(row, "stock_qty"); stockQty > 0 { - stockTurnover = salesQty / stockQty + avgStock := floatFromMap(row, "avg_stock_"+suffix) + if avgStock <= 0 { + avgStock = floatFromMap(row, "stock_qty") + } + if avgStock > 0 { + stockTurnover = productPerformanceAnnualizedStockTurnover(salesQty, avgStock, productPerformancePeriodDays(row, suffix)) } } return productPerformanceProductScore( @@ -6267,13 +6280,17 @@ func productPerformanceProductScore(suffix string, salesUSD, salesIndex, margin, } revenueScore := productPerformanceRevenueScore(suffix, salesUSD, salesIndex, productPerformanceProductRevenueTarget(suffix)) score := 0.30*productPerformanceMarginComponentScore(margin) + - 0.20*productPerformanceRatioScore(stockTurnover, 1.50) + + 0.20*productPerformanceRatioScore(stockTurnover, productPerformanceStockTurnoverTarget(suffix)) + 0.20*revenueScore + 0.15*productPerformanceRatioScore(marketCount, 8) + 0.15*productPerformanceRatioScore(customerCount, 25) return productPerformanceRoundScore(score) } +func productPerformanceStockTurnoverTarget(suffix string) float64 { + return 4 +} + func productPerformanceCustomerScore(suffix string, salesUSD, margin, productGroupCount, salesQty float64) float64 { if salesUSD <= 0 { return 1 diff --git a/svc/queries/product_performance_sql.go b/svc/queries/product_performance_sql.go index e2e8591..00f125c 100644 --- a/svc/queries/product_performance_sql.go +++ b/svc/queries/product_performance_sql.go @@ -638,10 +638,10 @@ Scored AS ( CASE WHEN b.avg_daily_sales_180d = 0 THEN 0 ELSE b.avg_stock_180d / NULLIF(b.avg_daily_sales_180d, 0) END AS stock_days_180d, CASE WHEN b.avg_daily_sales_365d = 0 THEN 0 ELSE b.avg_stock_365d / NULLIF(b.avg_daily_sales_365d, 0) END AS stock_days_365d, CASE WHEN b.avg_daily_sales_total = 0 THEN 0 ELSE b.avg_stock_total / NULLIF(b.avg_daily_sales_total, 0) END AS stock_days_total, - CASE WHEN b.avg_stock_90d = 0 THEN 0 ELSE b.sales_qty_90d / NULLIF(b.avg_stock_90d, 0) END AS stock_turnover_90d, - CASE WHEN b.avg_stock_180d = 0 THEN 0 ELSE b.sales_qty_180d / NULLIF(b.avg_stock_180d, 0) END AS stock_turnover_180d, + CASE WHEN b.avg_stock_90d = 0 THEN 0 ELSE b.sales_qty_90d / NULLIF(b.avg_stock_90d, 0) * 4.0 END AS stock_turnover_90d, + CASE WHEN b.avg_stock_180d = 0 THEN 0 ELSE b.sales_qty_180d / NULLIF(b.avg_stock_180d, 0) * 2.0 END AS stock_turnover_180d, CASE WHEN b.avg_stock_365d = 0 THEN 0 ELSE b.sales_qty_365d / NULLIF(b.avg_stock_365d, 0) END AS stock_turnover_365d, - CASE WHEN b.avg_stock_total = 0 THEN 0 ELSE b.sales_qty_total / NULLIF(b.avg_stock_total, 0) END AS stock_turnover_total, + CASE WHEN b.avg_stock_total = 0 THEN 0 ELSE b.sales_qty_total / NULLIF(b.avg_stock_total, 0) * 360.0 / GREATEST(1, ($1::date - DATE '2022-01-01') + 1) END AS stock_turnover_total, CASE WHEN COALESCE(m.market_avg_sales_qty_90d, 0) = 0 THEN 0 ELSE b.sales_qty_90d / NULLIF(m.market_avg_sales_qty_90d, 0) END AS sales_index_90d, CASE WHEN COALESCE(m.market_avg_sales_qty_180d, 0) = 0 THEN 0 ELSE b.sales_qty_180d / NULLIF(m.market_avg_sales_qty_180d, 0) END AS sales_index_180d, CASE WHEN COALESCE(m.market_avg_sales_qty_365d, 0) = 0 THEN 0 ELSE b.sales_qty_365d / NULLIF(m.market_avg_sales_qty_365d, 0) END AS sales_index_365d, @@ -687,7 +687,7 @@ SELECT + 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) - + CASE WHEN COALESCE(stock_days_90d, 0) BETWEEN 10 AND 90 THEN 10 WHEN COALESCE(stock_days_90d, 0) > 180 THEN -10 ELSE 0 END + + LEAST(20, COALESCE(stock_turnover_90d, 0) / 4.0 * 20) , 4) END AS performance_score, CASE diff --git a/ui/src/pages/ProductPerformanceProfitability.vue b/ui/src/pages/ProductPerformanceProfitability.vue index e445c16..b6f1f32 100644 --- a/ui/src/pages/ProductPerformanceProfitability.vue +++ b/ui/src/pages/ProductPerformanceProfitability.vue @@ -3553,7 +3553,7 @@ function applyDerivedGroupMetrics (out, sourceRows, groupField = '') { 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 + out[`stock_turnover_${suffix}`] = annualizedStockTurnover(qty, turnoverBase, days) if (qty > 0) out[`avg_price_usd_${suffix}`] = sales / qty const { costPrice, basePrice } = periodCostPair(out, suffix) out[`base_price_usd_${suffix}`] = basePrice @@ -3606,7 +3606,7 @@ function applyDerivedGroupMetrics (out, sourceRows, groupField = '') { function productPerformancePeriodDays (row, suffix) { if (suffix === '90d') return 90 if (suffix === '180d') return 180 - if (suffix === '365d') return 365 + if (suffix === '365d') return 360 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) @@ -3648,6 +3648,7 @@ function productPeriodMetricSource (row, suffix) { const salesIndex = Number(row?.[`sales_index_${suffix}`] || 0) const qty = Number(row?.[`sales_qty_${suffix}`] || 0) const stockQty = Number(row?.stock_qty || 0) + const avgStock = Number(row?.[`avg_stock_${suffix}`] || 0) || stockQty const turnoverKey = `stock_turnover_${suffix}` const hasTurnover = Object.prototype.hasOwnProperty.call(row || {}, turnoverKey) return { @@ -3657,7 +3658,7 @@ function productPeriodMetricSource (row, suffix) { gross_margin_cost_90d: Number(row?.[`gross_margin_cost_${suffix}`] ?? 0), gross_margin_90d: Number(row?.[`gross_margin_${suffix}`] ?? 0), sales_qty_90d: qty, - stock_turnover_90d: hasTurnover ? Number(row?.[turnoverKey] || 0) : (stockQty > 0 ? qty / stockQty : 0), + stock_turnover_90d: hasTurnover ? Number(row?.[turnoverKey] || 0) : annualizedStockTurnover(qty, avgStock, productPerformancePeriodDays(row, suffix)), market_count_90d: periodCount(row, 'market_count', suffix), customer_count_90d: periodCount(row, 'customer_count', suffix), stock_qty: stockQty @@ -3704,7 +3705,7 @@ function productScore100 ({ suffix = '90d', salesUSD = 0, margin = 0, stockTurno const revenue = ratioScore(salesUSD, productRevenueTarget(suffix)) return clampScore( 0.30 * marginScore(margin) + - 0.20 * ratioScore(stockTurnover, 1.5) + + 0.20 * ratioScore(stockTurnover, stockTurnoverTarget(suffix)) + 0.20 * revenue + 0.15 * ratioScore(marketCount, 8) + 0.15 * ratioScore(customerCount, 25) @@ -3759,6 +3760,21 @@ function customerQtyTarget (suffix) { return 500 } +const STOCK_TURNOVER_YEAR_DAYS = 360 + +function annualizedStockTurnover (salesQty, avgStock, periodDays) { + const qty = Number(salesQty || 0) + const stock = Number(avgStock || 0) + const days = Number(periodDays || 0) + if (!Number.isFinite(qty) || !Number.isFinite(stock) || qty <= 0 || stock <= 0) return 0 + const raw = qty / stock + return days > 0 ? raw * STOCK_TURNOVER_YEAR_DAYS / days : raw +} + +function stockTurnoverTarget () { + return 4 +} + function periodCount (row, prefix, suffix) { const keyed = Number(row?.[`${prefix}_${suffix}`] || 0) if (keyed > 0) return keyed @@ -4206,10 +4222,10 @@ function withProductMargins (row) { const out = { ...next, avg_price_usd_365d: Number(next?.sales_qty_365d || 0) > 0 ? Number(next?.sales_usd_365d || 0) / Number(next?.sales_qty_365d || 0) : 0, - stock_turnover_90d: existingOrStockTurnover(next?.stock_turnover_90d, next?.sales_qty_90d, next?.stock_qty), - stock_turnover_180d: existingOrStockTurnover(next?.stock_turnover_180d, next?.sales_qty_180d, next?.stock_qty), - stock_turnover_365d: existingOrStockTurnover(next?.stock_turnover_365d, next?.sales_qty_365d, next?.stock_qty), - stock_turnover_total: existingOrStockTurnover(next?.stock_turnover_total, next?.sales_qty_total, next?.stock_qty) + stock_turnover_90d: existingOrStockTurnover(next?.stock_turnover_90d, next?.sales_qty_90d, next?.avg_stock_90d || next?.stock_qty, 90), + stock_turnover_180d: existingOrStockTurnover(next?.stock_turnover_180d, next?.sales_qty_180d, next?.avg_stock_180d || next?.stock_qty, 180), + stock_turnover_365d: existingOrStockTurnover(next?.stock_turnover_365d, next?.sales_qty_365d, next?.avg_stock_365d || next?.stock_qty, 360), + stock_turnover_total: existingOrStockTurnover(next?.stock_turnover_total, next?.sales_qty_total, next?.avg_stock_total || next?.stock_qty, productPerformancePeriodDays(next, 'total')) } for (const suffix of ['90d', '180d', '365d', 'total']) { applyPeriodProfitFields(out, suffix) @@ -4222,7 +4238,7 @@ function withGeneralMargins (row) { applyPeriodProfitFields(out, 'total') return { ...out, - stock_turnover_total: existingOrStockTurnover(out?.stock_turnover_total, out?.sales_qty_total, out?.stock_qty) + stock_turnover_total: existingOrStockTurnover(out?.stock_turnover_total, out?.sales_qty_total, out?.avg_stock_total || out?.stock_qty, productPerformancePeriodDays(out, 'total')) } } @@ -4289,15 +4305,13 @@ function applyPeriodProfitFields (row, suffix) { row[`gross_margin_cost_${suffix}`] = marginFromSalesCost(salesUSD, qty, costPrice) } -function stockTurnover (salesQty, stockQty) { - const stock = Number(stockQty || 0) - if (stock <= 0) return 0 - return Number(salesQty || 0) / stock +function stockTurnover (salesQty, stockQty, periodDays) { + return annualizedStockTurnover(salesQty, stockQty, periodDays) } -function existingOrStockTurnover (value, salesQty, stockQty) { +function existingOrStockTurnover (value, salesQty, stockQty, periodDays) { const current = Number(value) - return value !== undefined && value !== null && Number.isFinite(current) ? current : stockTurnover(salesQty, stockQty) + return value !== undefined && value !== null && Number.isFinite(current) ? current : stockTurnover(salesQty, stockQty, periodDays) } function filterKey (tableKey, name) {