diff --git a/svc/queries/product_performance.go b/svc/queries/product_performance.go index cf87a5c..d6cbd7c 100644 --- a/svc/queries/product_performance.go +++ b/svc/queries/product_performance.go @@ -4592,10 +4592,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) * 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) * 360.0 / GREATEST(1, ((SELECT kpi_date FROM LatestKPIDate) - DATE '2022-01-01') + 1) ELSE 0 END + 'stock_turnover_90d', stock_turnover_90d, + 'stock_turnover_180d', stock_turnover_180d, + 'stock_turnover_365d', stock_turnover_365d, + 'stock_turnover_total', stock_turnover_total ) ) AS row_json FROM ( @@ -4660,10 +4660,10 @@ FROM ( CASE WHEN SUM(sales_qty_180d) > 0 THEN SUM(unit_profit_cost_180d * sales_qty_180d) / NULLIF(SUM(sales_qty_180d),0) ELSE 0 END AS unit_profit_cost_180d, CASE WHEN SUM(sales_qty_90d) > 0 THEN SUM(unit_profit_base_90d * sales_qty_90d) / NULLIF(SUM(sales_qty_90d),0) ELSE 0 END AS unit_profit_base_90d, CASE WHEN SUM(sales_qty_180d) > 0 THEN SUM(unit_profit_base_180d * sales_qty_180d) / NULLIF(SUM(sales_qty_180d),0) ELSE 0 END AS unit_profit_base_180d, - COUNT(DISTINCT NULLIF(NULLIF(market_key,'STOK'),'')) FILTER (WHERE COALESCE(sales_usd_90d,0) > 0)::integer AS market_count_90d, - COUNT(DISTINCT NULLIF(NULLIF(market_key,'STOK'),'')) FILTER (WHERE COALESCE(sales_usd_180d,0) > 0)::integer AS market_count_180d, - COUNT(DISTINCT NULLIF(NULLIF(market_key,'STOK'),'')) FILTER (WHERE COALESCE(sales_usd_365d,0) > 0)::integer AS market_count_365d, - COUNT(DISTINCT NULLIF(NULLIF(market_key,'STOK'),'')) FILTER (WHERE COALESCE(sales_usd_total,0) > 0)::integer AS market_count_total, + COUNT(DISTINCT NULLIF(NULLIF(display_market_key,'STOK'),'')) FILTER (WHERE COALESCE(sales_usd_90d,0) > 0)::integer AS market_count_90d, + COUNT(DISTINCT NULLIF(NULLIF(display_market_key,'STOK'),'')) FILTER (WHERE COALESCE(sales_usd_180d,0) > 0)::integer AS market_count_180d, + COUNT(DISTINCT NULLIF(NULLIF(display_market_key,'STOK'),'')) FILTER (WHERE COALESCE(sales_usd_365d,0) > 0)::integer AS market_count_365d, + COUNT(DISTINCT NULLIF(NULLIF(display_market_key,'STOK'),'')) FILTER (WHERE COALESCE(sales_usd_total,0) > 0)::integer AS market_count_total, COALESCE(SUM(CASE WHEN COALESCE(sales_usd_90d,0) > 0 THEN customer_count_90d ELSE 0 END),0)::integer AS customer_count_90d, COALESCE(SUM(CASE WHEN COALESCE(sales_usd_180d,0) > 0 THEN customer_count_180d ELSE 0 END),0)::integer AS customer_count_180d, COALESCE(SUM(CASE WHEN COALESCE(sales_usd_365d,0) > 0 THEN customer_count_365d ELSE 0 END),0)::integer AS customer_count_365d, @@ -4674,6 +4674,10 @@ FROM ( CASE WHEN SUM(sales_qty_total) > 0 THEN SUM(sales_index_total * sales_qty_total) / NULLIF(SUM(sales_qty_total),0) ELSE 0 END AS sales_index_total, CASE WHEN SUM(sales_qty_90d) > 0 THEN SUM(price_index_90d * sales_qty_90d) / NULLIF(SUM(sales_qty_90d),0) ELSE 0 END AS price_index_90d, CASE WHEN SUM(sales_qty_90d) > 0 THEN SUM(margin_index_90d * sales_qty_90d) / NULLIF(SUM(sales_qty_90d),0) ELSE 0 END AS margin_index_90d, + CASE WHEN SUM(sales_qty_90d) > 0 THEN SUM(stock_turnover_90d * sales_qty_90d) / NULLIF(SUM(sales_qty_90d),0) ELSE 0 END AS stock_turnover_90d, + CASE WHEN SUM(sales_qty_180d) > 0 THEN SUM(stock_turnover_180d * sales_qty_180d) / NULLIF(SUM(sales_qty_180d),0) ELSE 0 END AS stock_turnover_180d, + CASE WHEN SUM(sales_qty_365d) > 0 THEN SUM(stock_turnover_365d * sales_qty_365d) / NULLIF(SUM(sales_qty_365d),0) ELSE 0 END AS stock_turnover_365d, + CASE WHEN SUM(sales_qty_total) > 0 THEN SUM(stock_turnover_total * sales_qty_total) / NULLIF(SUM(sales_qty_total),0) ELSE 0 END AS stock_turnover_total, CASE WHEN SUM(CASE WHEN COALESCE(sales_qty_90d,0) > 0 THEN sales_qty_90d @@ -4694,6 +4698,11 @@ FROM ( FROM ( SELECT Source.*, + btrim(regexp_replace(COALESCE(Source.market_key,''), '^.*[|]', '')) AS display_market_key, + 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) * 360.0 / GREATEST(1, ((SELECT kpi_date FROM LatestKPIDate) - DATE '2022-01-01') + 1) ELSE 0 END AS stock_turnover_total, ROW_NUMBER() OVER ( PARTITION BY COALESCE(%s, ''), product_code, color_code, yaka_kodu ORDER BY performance_score DESC NULLS LAST @@ -4886,7 +4895,7 @@ func productPerformanceSQLGroupExpr(field string) (string, bool) { case "color_yaka": return "concat_ws('/', NULLIF(btrim(COALESCE(color_code,'')), ''), NULLIF(btrim(COALESCE(yaka_kodu,'')), ''))", true case "market_key": - return "btrim(regexp_replace(COALESCE(market_key,''), '^.*\\|', ''))", true + return "btrim(regexp_replace(COALESCE(market_key,''), '^.*[|]', ''))", true default: return "", false } @@ -5300,6 +5309,26 @@ Spread AS ( s.color_code, s.yaka_kodu, s.market_key, + COALESCE(jsonb_agg(DISTINCT display_market_key) FILTER ( + WHERE s.sales_date BETWEEN Latest.kpi_date - INTERVAL '89 days' AND Latest.kpi_date + AND COALESCE(s.sales_usd,0) > 0 + AND display_market_key NOT IN ('', 'STOK', '-') + ), '[]'::jsonb) AS market_keys_90d, + COALESCE(jsonb_agg(DISTINCT display_market_key) FILTER ( + WHERE s.sales_date BETWEEN Latest.kpi_date - INTERVAL '179 days' AND Latest.kpi_date + AND COALESCE(s.sales_usd,0) > 0 + AND display_market_key NOT IN ('', 'STOK', '-') + ), '[]'::jsonb) AS market_keys_180d, + COALESCE(jsonb_agg(DISTINCT display_market_key) FILTER ( + WHERE s.sales_date BETWEEN Latest.kpi_date - INTERVAL '359 days' AND Latest.kpi_date + AND COALESCE(s.sales_usd,0) > 0 + AND display_market_key NOT IN ('', 'STOK', '-') + ), '[]'::jsonb) AS market_keys_365d, + COALESCE(jsonb_agg(DISTINCT display_market_key) FILTER ( + WHERE s.sales_date BETWEEN DATE '2022-01-01' AND Latest.kpi_date + AND COALESCE(s.sales_usd,0) > 0 + AND display_market_key NOT IN ('', 'STOK', '-') + ), '[]'::jsonb) AS market_keys_total, COALESCE(jsonb_agg(DISTINCT btrim(s.customer_code)) FILTER ( WHERE s.sales_date BETWEEN Latest.kpi_date - INTERVAL '89 days' AND Latest.kpi_date AND COALESCE(s.sales_usd,0) > 0 @@ -5320,7 +5349,12 @@ Spread AS ( AND COALESCE(s.sales_usd,0) > 0 AND btrim(COALESCE(s.customer_code,'')) NOT IN ('', '-') ), '[]'::jsonb) AS customer_keys_total - FROM mk_product_performance_sales_daily s + FROM ( + SELECT + s.*, + btrim(regexp_replace(COALESCE(s.market_key,''), '^.*[|]', '')) AS display_market_key + FROM mk_product_performance_sales_daily s + ) s CROSS JOIN Latest WHERE s.sales_date BETWEEN DATE '2022-01-01' AND Latest.kpi_date AND upper(translate(btrim(COALESCE(s.urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER') @@ -5331,6 +5365,10 @@ SELECT jsonb_build_object( 'color_code', color_code, 'yaka_kodu', yaka_kodu, 'market_key', market_key, + '__market_keys_90d', market_keys_90d, + '__market_keys_180d', market_keys_180d, + '__market_keys_365d', market_keys_365d, + '__market_keys_total', market_keys_total, '__customer_keys_90d', customer_keys_90d, '__customer_keys_180d', customer_keys_180d, '__customer_keys_365d', customer_keys_365d, @@ -5355,9 +5393,11 @@ FROM Spread` continue } for _, suffix := range productPerformancePeriodSuffixes() { - field := "__customer_keys_" + suffix - if value, ok := spread[field]; ok { - row[field] = value + for _, prefix := range []string{"__market_keys_", "__customer_keys_"} { + field := prefix + suffix + if value, ok := spread[field]; ok { + row[field] = value + } } } } @@ -5569,6 +5609,13 @@ func applyProductPerformanceGroupedChildScoreAverages(rows []map[string]any, mod } row[field] = weightedAverageProductPerformanceScoreRows(childRows, field, suffix) } + for _, suffix := range productPerformancePeriodSuffixes() { + field := "stock_turnover_" + suffix + if !productPerformanceRowsHaveField(childRows, field) { + continue + } + row[field] = weightedAverageProductPerformanceRows(childRows, field, "sales_qty_"+suffix) + } if score, ok := productPerformanceOptionalFloat(row, "performance_score_90d"); ok { row["performance_score"] = score } @@ -5710,6 +5757,7 @@ func (n *productPerformanceGroupedSnapshotNode) addDistinctSpread(row map[string } } for _, suffix := range productPerformancePeriodSuffixes() { + n.MarketSeen = productPerformanceAddSeenStrings(n.MarketSeen, suffix, productPerformanceStringSliceFromMap(row, "__market_keys_"+suffix)) n.CustomerSeen = productPerformanceAddSeenStrings(n.CustomerSeen, suffix, productPerformanceStringSliceFromMap(row, "__customer_keys_"+suffix)) } } @@ -5940,6 +5988,7 @@ func addProductPerformanceDistinctSpread(row map[string]any, marketSeen, custome } } for _, suffix := range productPerformancePeriodSuffixes() { + marketSeen = productPerformanceAddSeenStrings(marketSeen, suffix, productPerformanceStringSliceFromMap(row, "__market_keys_"+suffix)) customerSeen = productPerformanceAddSeenStrings(customerSeen, suffix, productPerformanceStringSliceFromMap(row, "__customer_keys_"+suffix)) } } @@ -6135,6 +6184,7 @@ func deriveProductPerformanceGroupMetrics(out map[string]any, groupField string) } preservedCustomerScores := map[string]float64{} preservedProductScores := map[string]float64{} + preservedStockTurnovers := map[string]float64{} for _, suffix := range productPerformancePeriodSuffixes() { if score, ok := productPerformanceOptionalFloat(out, "customer_score_"+suffix); ok { preservedCustomerScores[suffix] = score @@ -6142,6 +6192,11 @@ func deriveProductPerformanceGroupMetrics(out map[string]any, groupField string) if score, ok := productPerformanceOptionalFloat(out, "performance_score_"+suffix); ok { preservedProductScores[suffix] = score } + if isGroup { + if turnover, ok := productPerformanceOptionalFloat(out, "stock_turnover_"+suffix); ok { + preservedStockTurnovers[suffix] = turnover + } + } } if score, ok := productPerformanceOptionalFloat(out, "performance_score"); ok { if _, exists := preservedProductScores["90d"]; !exists { @@ -6170,6 +6225,9 @@ func deriveProductPerformanceGroupMetrics(out map[string]any, groupField string) out["stock_days_"+suffix] = 0 } out["stock_turnover_"+suffix] = productPerformanceAnnualizedStockTurnover(qty, turnoverBase, days) + if turnover, ok := preservedStockTurnovers[suffix]; ok { + out["stock_turnover_"+suffix] = turnover + } if qty > 0 { out["avg_price_usd_"+suffix] = sales / qty } @@ -6775,6 +6833,9 @@ func productPerformanceMetricWeightField(field string) string { if productPerformanceUsesCostWeight(field) { return "__product_cost_weight" } + if strings.HasPrefix(field, "stock_turnover") { + return "sales_qty_" + productPerformanceScoreSuffix(field) + } if strings.Contains(field, "_180d") { return "sales_qty_180d" } diff --git a/ui/src/pages/ProductPerformanceProfitability.vue b/ui/src/pages/ProductPerformanceProfitability.vue index 968bcec..b39d8b7 100644 --- a/ui/src/pages/ProductPerformanceProfitability.vue +++ b/ui/src/pages/ProductPerformanceProfitability.vue @@ -3525,6 +3525,7 @@ function shouldAverageField (field) { } function weightFieldForMetric (field) { + if (field.startsWith('stock_turnover')) return `sales_qty_${countPeriodSuffix(field)}` if (field.includes('_180d')) return 'sales_qty_180d' if (field.includes('_365d')) return 'sales_qty_365d' if (field.includes('_total')) return 'sales_qty_total' @@ -3538,6 +3539,7 @@ function applyDerivedGroupMetrics (out, sourceRows, groupField = '') { const isGroup = Boolean(String(groupField || '').trim()) const preservedCustomerScores = {} const preservedProductScores = {} + const preservedStockTurnovers = {} for (const suffix of ['90d', '180d', '365d', 'total']) { if (Object.prototype.hasOwnProperty.call(out, `customer_score_${suffix}`)) { preservedCustomerScores[suffix] = Number(out[`customer_score_${suffix}`] || 0) @@ -3545,6 +3547,9 @@ function applyDerivedGroupMetrics (out, sourceRows, groupField = '') { if (Object.prototype.hasOwnProperty.call(out, `performance_score_${suffix}`)) { preservedProductScores[suffix] = Number(out[`performance_score_${suffix}`] || 0) } + if (isGroup && Object.prototype.hasOwnProperty.call(out, `stock_turnover_${suffix}`)) { + preservedStockTurnovers[suffix] = Number(out[`stock_turnover_${suffix}`] || 0) + } } if ( Object.prototype.hasOwnProperty.call(out, 'performance_score') && @@ -3565,6 +3570,9 @@ function applyDerivedGroupMetrics (out, sourceRows, groupField = '') { 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}`] = annualizedStockTurnover(qty, turnoverBase, days) + if (Object.prototype.hasOwnProperty.call(preservedStockTurnovers, suffix)) { + out[`stock_turnover_${suffix}`] = preservedStockTurnovers[suffix] + } if (qty > 0) out[`avg_price_usd_${suffix}`] = sales / qty const { costPrice, basePrice } = periodCostPair(out, suffix) out[`base_price_usd_${suffix}`] = basePrice @@ -3917,6 +3925,12 @@ function distinctRevenueMarketCount (sourceRows, suffix, fallbackField) { const values = new Set() for (const row of sourceRows) { if (Number(row?.[salesField] || 0) <= 0) continue + const marketKeys = stringListFromRow(row, `__market_keys_${suffix}`) + for (const key of marketKeys) { + const value = displayMarketName(key) + if (value && value !== 'STOK' && value !== '-') values.add(value) + } + if (marketKeys.length > 0) continue const value = displayMarketName(row?.market_key) if (value && value !== 'STOK' && value !== '-') values.add(value) } @@ -3931,6 +3945,11 @@ function distinctRevenueCustomerCount (sourceRows, suffix, fallbackField) { const values = new Set() for (const row of sourceRows) { if (Number(row?.[salesField] || 0) <= 0) continue + const customerKeys = stringListFromRow(row, `__customer_keys_${suffix}`) + for (const code of customerKeys) { + if (code && code !== '-') values.add(code) + } + if (customerKeys.length > 0) continue const code = String(row?.customer_code || '').trim() if (code && code !== '-') values.add(code) } @@ -3940,6 +3959,22 @@ function distinctRevenueCustomerCount (sourceRows, suffix, fallbackField) { }, 0) } +function stringListFromRow (row, field) { + const value = row?.[field] + if (Array.isArray(value)) return value.map(item => String(item || '').trim()).filter(Boolean) + const text = String(value || '').trim() + if (!text) return [] + if (text.startsWith('[')) { + try { + const parsed = JSON.parse(text) + return Array.isArray(parsed) ? parsed.map(item => String(item || '').trim()).filter(Boolean) : [] + } catch { + return [] + } + } + return [text] +} + function distinctSummary (sourceRows, field, fallbackField) { const values = new Set() for (const row of sourceRows) { @@ -4351,9 +4386,10 @@ function groupedOrExistingStockTurnover (row, suffix, periodDays) { const avgStock = Number(row?.[`avg_stock_${suffix}`] || 0) const stockQty = Number(row?.stock_qty || 0) if (row?.__group) { - if (avgStock > 0) return stockTurnover(salesQty, avgStock, periodDays) const current = Number(row?.[`stock_turnover_${suffix}`]) - return Number.isFinite(current) ? current : 0 + if (Number.isFinite(current)) return current + if (avgStock > 0) return stockTurnover(salesQty, avgStock, periodDays) + return 0 } const stockBase = avgStock > 0 ? avgStock : stockQty return existingOrStockTurnover(row?.[`stock_turnover_${suffix}`], salesQty, stockBase, periodDays)