import express from 'express'; import db from '../utils/db.js'; const router = express.Router(); // Helper function to execute queries using the connection pool async function executeQuery(sql, params = []) { const pool = db.getPool(); if (!pool) { throw new Error('Database pool not initialized'); } return pool.query(sql, params); } // GET /dashboard/stock/metrics // Returns brand-level stock metrics router.get('/stock/metrics', async (req, res) => { try { // Get stock metrics const { rows: [stockMetrics] } = await executeQuery(` SELECT COALESCE(COUNT(*), 0)::integer as total_products, COALESCE(COUNT(CASE WHEN current_stock > 0 THEN 1 END), 0)::integer as products_in_stock, COALESCE(SUM(CASE WHEN current_stock > 0 THEN current_stock END), 0)::integer as total_units, ROUND(COALESCE(SUM(CASE WHEN current_stock > 0 THEN current_stock_cost END), 0)::numeric, 2) as total_cost, ROUND(COALESCE(SUM(CASE WHEN current_stock > 0 THEN current_stock_retail END), 0)::numeric, 2) as total_retail FROM product_metrics WHERE is_visible = true `); // Get brand stock values with Other category const { rows: brandValues } = await executeQuery(` WITH brand_totals AS ( SELECT COALESCE(brand, 'Unbranded') as brand, COUNT(DISTINCT pid)::integer as variant_count, COALESCE(SUM(current_stock), 0)::integer as stock_units, ROUND(COALESCE(SUM(current_stock_cost), 0)::numeric, 2) as stock_cost, ROUND(COALESCE(SUM(current_stock_retail), 0)::numeric, 2) as stock_retail FROM product_metrics WHERE current_stock > 0 AND is_visible = true GROUP BY COALESCE(brand, 'Unbranded') HAVING ROUND(COALESCE(SUM(current_stock_cost), 0)::numeric, 2) > 0 ), other_brands AS ( SELECT 'Other' as brand, SUM(variant_count)::integer as variant_count, SUM(stock_units)::integer as stock_units, ROUND(SUM(stock_cost)::numeric, 2) as stock_cost, ROUND(SUM(stock_retail)::numeric, 2) as stock_retail FROM brand_totals WHERE stock_cost <= 5000 ), main_brands AS ( SELECT * FROM brand_totals WHERE stock_cost > 5000 ), combined_results AS ( SELECT * FROM main_brands UNION ALL SELECT * FROM other_brands WHERE stock_cost > 0 ) SELECT * FROM combined_results ORDER BY CASE WHEN brand = 'Other' THEN 1 ELSE 0 END, stock_cost DESC `); // Stock breakdown by lifecycle phase (lifecycle_phase populated by update_lifecycle_forecasts.sql) const { rows: phaseStock } = await executeQuery(` SELECT COALESCE(pm.lifecycle_phase, 'unknown') AS phase, COUNT(DISTINCT pm.pid)::integer AS products, COALESCE(SUM(pm.current_stock), 0)::integer AS units, ROUND(COALESCE(SUM(pm.current_stock_cost), 0)::numeric, 2) AS cost, ROUND(COALESCE(SUM(pm.current_stock_retail), 0)::numeric, 2) AS retail FROM product_metrics pm WHERE pm.is_visible = true AND pm.current_stock > 0 AND COALESCE(pm.preorder_count, 0) = 0 GROUP BY pm.lifecycle_phase ORDER BY cost DESC `); const phaseTotalCost = phaseStock.reduce((s, r) => s + (parseFloat(r.cost) || 0), 0); // Format the response with explicit type conversion const response = { totalProducts: parseInt(stockMetrics.total_products) || 0, productsInStock: parseInt(stockMetrics.products_in_stock) || 0, totalStockUnits: parseInt(stockMetrics.total_units) || 0, totalStockCost: parseFloat(stockMetrics.total_cost) || 0, totalStockRetail: parseFloat(stockMetrics.total_retail) || 0, brandStock: brandValues.map(v => ({ brand: v.brand, variants: parseInt(v.variant_count) || 0, units: parseInt(v.stock_units) || 0, cost: parseFloat(v.stock_cost) || 0, retail: parseFloat(v.stock_retail) || 0 })), phaseStock: phaseStock.filter(r => parseFloat(r.cost) > 0).map(r => ({ phase: r.phase, products: parseInt(r.products) || 0, units: parseInt(r.units) || 0, cost: parseFloat(r.cost) || 0, retail: parseFloat(r.retail) || 0, percentage: phaseTotalCost > 0 ? parseFloat(((parseFloat(r.cost) / phaseTotalCost) * 100).toFixed(1)) : 0, })), }; res.json(response); } catch (err) { console.error('Error fetching stock metrics:', err); res.status(500).json({ error: 'Failed to fetch stock metrics' }); } }); // GET /dashboard/purchase/metrics // Returns purchase order metrics by vendor router.get('/purchase/metrics', async (req, res) => { try { // Active/overdue PO counts (staleness-filtered, from purchase_orders directly) const activePOs = await executeQuery(` WITH stale AS ( SELECT po_id, pid FROM purchase_orders po WHERE po.status IN ('created', 'ordered', 'preordered', 'electronically_sent', 'electronically_ready_send', 'receiving_started') AND po.expected_date IS NOT NULL AND po.expected_date < CURRENT_DATE - INTERVAL '90 days' AND EXISTS ( SELECT 1 FROM purchase_orders newer WHERE newer.pid = po.pid AND newer.status NOT IN ('canceled', 'done') AND COALESCE(newer.date_ordered, newer.date_created) > COALESCE(po.date_ordered, po.date_created) ) ) SELECT COUNT(DISTINCT po_id)::integer AS active_pos, COUNT(DISTINCT CASE WHEN expected_date < CURRENT_DATE THEN po_id END)::integer AS overdue_pos FROM purchase_orders po WHERE po.status NOT IN ('canceled', 'done') AND NOT EXISTS (SELECT 1 FROM stale s WHERE s.po_id = po.po_id AND s.pid = po.pid) `); const poMetrics = activePOs.rows[0]; // On-order totals and vendor breakdown from product_metrics (FIFO-computed) // Consistent with Analytics and Pipeline components const { rows: vendorRows } = await executeQuery(` SELECT vendor, SUM(on_order_qty)::integer AS units, ROUND(SUM(on_order_cost)::numeric, 2) AS cost, ROUND(SUM(on_order_retail)::numeric, 2) AS retail, SUM(SUM(on_order_qty)::integer) OVER () AS total_units, ROUND(SUM(SUM(on_order_cost)) OVER ()::numeric, 2) AS total_cost, ROUND(SUM(SUM(on_order_retail)) OVER ()::numeric, 2) AS total_retail FROM product_metrics WHERE is_visible = true AND on_order_qty > 0 GROUP BY vendor ORDER BY cost DESC `); const vendorOrders = vendorRows.map(v => ({ vendor: v.vendor, orders: 0, units: parseInt(v.units) || 0, cost: parseFloat(v.cost) || 0, retail: parseFloat(v.retail) || 0 })); const firstRow = vendorRows[0]; const onOrderUnits = firstRow ? parseInt(firstRow.total_units) || 0 : 0; const onOrderCost = firstRow ? parseFloat(firstRow.total_cost) || 0 : 0; const onOrderRetail = firstRow ? parseFloat(firstRow.total_retail) || 0 : 0; // Format response to match PurchaseMetricsData interface const response = { activePurchaseOrders: parseInt(poMetrics.active_pos) || 0, overduePurchaseOrders: parseInt(poMetrics.overdue_pos) || 0, onOrderUnits, onOrderCost, onOrderRetail, vendorOrders }; res.json(response); } catch (err) { console.error('Error fetching purchase metrics:', err); res.status(500).json({ error: 'Failed to fetch purchase metrics' }); } }); // GET /dashboard/replenishment/metrics // Returns replenishment needs by category router.get('/replenishment/metrics', async (req, res) => { try { // Get summary metrics const { rows: [metrics] } = await executeQuery(` SELECT COUNT(DISTINCT pm.pid)::integer as products_to_replenish, COALESCE(SUM(pm.replenishment_units), 0)::integer as total_units_needed, ROUND(COALESCE(SUM(pm.replenishment_cost), 0)::numeric, 2) as total_cost, ROUND(COALESCE(SUM(pm.replenishment_retail), 0)::numeric, 2) as total_retail FROM product_metrics pm WHERE pm.is_visible = true AND pm.is_replenishable = true AND (pm.status IN ('Critical', 'Reorder') OR pm.current_stock < 0) AND pm.replenishment_units > 0 `); // Get top variants to replenish const { rows: variants } = await executeQuery(` SELECT pm.pid, pm.title, pm.current_stock::integer as current_stock, pm.replenishment_units::integer as replenish_qty, ROUND(pm.replenishment_cost::numeric, 2) as replenish_cost, ROUND(pm.replenishment_retail::numeric, 2) as replenish_retail, pm.status, pm.planning_period_days::text as planning_period FROM product_metrics pm WHERE pm.is_visible = true AND pm.is_replenishable = true AND (pm.status IN ('Critical', 'Reorder') OR pm.current_stock < 0) AND pm.replenishment_units > 0 ORDER BY CASE pm.status WHEN 'Critical' THEN 1 WHEN 'Reorder' THEN 2 END, replenish_cost DESC LIMIT 5 `); // Replenishment breakdown by lifecycle phase (lifecycle_phase on product_metrics) const { rows: phaseReplenish } = await executeQuery(` SELECT COALESCE(pm.lifecycle_phase, 'unknown') AS phase, COUNT(DISTINCT pm.pid)::integer AS products, COALESCE(SUM(pm.replenishment_units), 0)::integer AS units, ROUND(COALESCE(SUM(pm.replenishment_cost), 0)::numeric, 2) AS cost FROM product_metrics pm WHERE pm.is_visible = true AND pm.is_replenishable = true AND (pm.status IN ('Critical', 'Reorder') OR pm.current_stock < 0) AND pm.replenishment_units > 0 GROUP BY pm.lifecycle_phase ORDER BY cost DESC `); const replenishTotalCost = phaseReplenish.reduce((s, r) => s + (parseFloat(r.cost) || 0), 0); // Format response const response = { productsToReplenish: parseInt(metrics.products_to_replenish) || 0, unitsToReplenish: parseInt(metrics.total_units_needed) || 0, replenishmentCost: parseFloat(metrics.total_cost) || 0, replenishmentRetail: parseFloat(metrics.total_retail) || 0, phaseBreakdown: phaseReplenish.filter(r => parseFloat(r.cost) > 0).map(r => ({ phase: r.phase, products: parseInt(r.products) || 0, units: parseInt(r.units) || 0, cost: parseFloat(r.cost) || 0, percentage: replenishTotalCost > 0 ? parseFloat(((parseFloat(r.cost) / replenishTotalCost) * 100).toFixed(1)) : 0, })), topVariants: variants.map(v => ({ id: v.pid, title: v.title, currentStock: parseInt(v.current_stock) || 0, replenishQty: parseInt(v.replenish_qty) || 0, replenishCost: parseFloat(v.replenish_cost) || 0, replenishRetail: parseFloat(v.replenish_retail) || 0, status: v.status, planningPeriod: v.planning_period })) }; res.json(response); } catch (err) { console.error('Error fetching replenishment metrics:', err); res.status(500).json({ error: 'Failed to fetch replenishment metrics' }); } }); // GET /dashboard/forecast/metrics // Reads from product_forecasts table (lifecycle-aware forecasting pipeline). // Falls back to velocity-based projection if forecast table is empty. router.get('/forecast/metrics', async (req, res) => { const today = new Date(); const thirtyDaysOut = new Date(today); thirtyDaysOut.setDate(today.getDate() + 30); const startDate = req.query.startDate ? new Date(req.query.startDate) : today; const endDate = req.query.endDate ? new Date(req.query.endDate) : thirtyDaysOut; const startISO = startDate.toISOString().split('T')[0]; const endISO = endDate.toISOString().split('T')[0]; const days = Math.max(1, Math.round((endDate - startDate) / (1000 * 60 * 60 * 24))); try { // Check if product_forecasts has data const { rows: [countRow] } = await executeQuery( `SELECT COUNT(*) AS cnt FROM product_forecasts WHERE forecast_date >= $1 LIMIT 1`, [startISO] ); const hasForecastData = parseInt(countRow.cnt) > 0; if (hasForecastData) { // --- Read from lifecycle-aware forecast pipeline --- // Find the last date covered by product_forecasts const { rows: [horizonRow] } = await executeQuery( `SELECT MAX(forecast_date) AS max_date FROM product_forecasts` ); const forecastHorizonISO = horizonRow.max_date instanceof Date ? horizonRow.max_date.toISOString().split('T')[0] : horizonRow.max_date; const forecastHorizon = new Date(forecastHorizonISO + 'T00:00:00'); const clampedEndISO = endISO <= forecastHorizonISO ? endISO : forecastHorizonISO; const needsExtrapolation = endISO > forecastHorizonISO; // Totals from actual forecast data (clamped to horizon) const { rows: [totals] } = await executeQuery(` SELECT COALESCE(SUM(pf.forecast_units), 0) AS total_units, COALESCE(SUM(pf.forecast_revenue), 0) AS total_revenue, COUNT(DISTINCT pf.pid) FILTER ( WHERE pf.lifecycle_phase IN ('launch','decay','mature','preorder','slow_mover') ) AS active_products, COUNT(DISTINCT pf.pid) FILTER ( WHERE pf.forecast_method = 'lifecycle_curve' ) AS curve_products FROM product_forecasts pf JOIN product_metrics pm ON pm.pid = pf.pid WHERE pm.is_visible = true AND pf.forecast_date BETWEEN $1 AND $2 `, [startISO, clampedEndISO]); const active = parseInt(totals.active_products) || 1; const curveProducts = parseInt(totals.curve_products) || 0; const confidenceLevel = parseFloat((curveProducts / active).toFixed(2)); // Daily series from actual forecast const { rows: dailyRows } = await executeQuery(` SELECT pf.forecast_date AS date, SUM(pf.forecast_units) AS units, SUM(pf.forecast_revenue) AS revenue FROM product_forecasts pf JOIN product_metrics pm ON pm.pid = pf.pid WHERE pm.is_visible = true AND pf.forecast_date BETWEEN $1 AND $2 GROUP BY pf.forecast_date ORDER BY pf.forecast_date `, [startISO, clampedEndISO]); const dailyForecasts = dailyRows.map(d => ({ date: d.date instanceof Date ? d.date.toISOString().split('T')[0] : d.date, units: parseFloat(d.units) || 0, revenue: parseFloat(d.revenue) || 0, confidence: confidenceLevel, })); // Daily forecast broken down by lifecycle phase (for stacked chart) const { rows: dailyPhaseRows } = await executeQuery(` SELECT pf.forecast_date AS date, COALESCE(SUM(pf.forecast_revenue) FILTER (WHERE pf.lifecycle_phase = 'preorder'), 0) AS preorder, COALESCE(SUM(pf.forecast_revenue) FILTER (WHERE pf.lifecycle_phase = 'launch'), 0) AS launch, COALESCE(SUM(pf.forecast_revenue) FILTER (WHERE pf.lifecycle_phase = 'decay'), 0) AS decay, COALESCE(SUM(pf.forecast_revenue) FILTER (WHERE pf.lifecycle_phase = 'mature'), 0) AS mature, COALESCE(SUM(pf.forecast_revenue) FILTER (WHERE pf.lifecycle_phase = 'slow_mover'), 0) AS slow_mover, COALESCE(SUM(pf.forecast_revenue) FILTER (WHERE pf.lifecycle_phase = 'dormant'), 0) AS dormant FROM product_forecasts pf JOIN product_metrics pm ON pm.pid = pf.pid WHERE pm.is_visible = true AND pf.forecast_date BETWEEN $1 AND $2 GROUP BY pf.forecast_date ORDER BY pf.forecast_date `, [startISO, clampedEndISO]); // --- New product pipeline contribution --- // Average daily revenue from new product introductions (last 12 months). // Only used for EXTRAPOLATED days beyond the forecast horizon — within the // 90-day horizon, preorder/launch products are already forecast by lifecycle curves. const { rows: [pipeline] } = await executeQuery(` SELECT COALESCE(AVG(monthly_revenue), 0) AS avg_monthly_revenue FROM ( SELECT DATE_TRUNC('month', pm.date_first_received) AS month, COUNT(*) AS monthly_products, SUM(pm.first_30_days_revenue) AS monthly_revenue FROM product_metrics pm WHERE pm.is_visible = true AND pm.date_first_received >= NOW() - INTERVAL '12 months' AND pm.date_first_received < DATE_TRUNC('month', NOW()) GROUP BY 1 ) sub `); // Compute average product price for converting revenue to unit estimates const { rows: [priceRow] } = await executeQuery(` SELECT COALESCE(AVG(current_price) FILTER (WHERE current_price > 0 AND sales_30d > 0), 7) AS avg_price FROM product_metrics WHERE is_visible = true `); const avgPrice = parseFloat(priceRow.avg_price) || 7; // Daily new-product revenue = (avg products/month × avg 30d revenue/product) / 30 const avgMonthlyRevenue = parseFloat(pipeline.avg_monthly_revenue) || 0; const newProductDailyRevenue = avgMonthlyRevenue / 30; const newProductDailyUnits = newProductDailyRevenue / avgPrice; let totalRevenue = dailyForecasts.reduce((sum, d) => sum + d.revenue, 0); let totalUnits = dailyForecasts.reduce((sum, d) => sum + d.units, 0); // --- Extrapolation beyond forecast horizon (rest-of-year) --- if (needsExtrapolation) { // Monthly seasonal indices from last 12 months of actual revenue const { rows: seasonalRows } = await executeQuery(` SELECT EXTRACT(MONTH FROM o.date)::int AS month, SUM(o.quantity * o.price) AS revenue FROM orders o WHERE o.canceled IS DISTINCT FROM TRUE AND o.date >= NOW() - INTERVAL '12 months' GROUP BY 1 `); const monthlyRevenue = {}; let totalMonthlyRev = 0; for (const r of seasonalRows) { monthlyRevenue[r.month] = parseFloat(r.revenue) || 0; totalMonthlyRev += monthlyRevenue[r.month]; } const avgMonthRev = totalMonthlyRev / Math.max(Object.keys(monthlyRevenue).length, 1); const seasonalIndex = {}; for (let m = 1; m <= 12; m++) { seasonalIndex[m] = monthlyRevenue[m] ? monthlyRevenue[m] / avgMonthRev : 1.0; } // Baseline: avg daily revenue from last 7 days of forecast (mature tail) const tailDays = dailyForecasts.slice(-7); const baselineDaily = tailDays.length > 0 ? tailDays.reduce((s, d) => s + d.revenue, 0) / tailDays.length : 0; // Generate estimated days beyond horizon const extraStart = new Date(forecastHorizon); extraStart.setDate(extraStart.getDate() + 1); const extraEnd = new Date(endISO + 'T00:00:00'); for (let d = new Date(extraStart); d <= extraEnd; d.setDate(d.getDate() + 1)) { const month = d.getMonth() + 1; const seasonal = seasonalIndex[month] || 1.0; // Beyond horizon: existing product tail + new product pipeline const estRevenue = baselineDaily * seasonal + newProductDailyRevenue; const estUnits = (baselineDaily * seasonal) / avgPrice + newProductDailyUnits; dailyForecasts.push({ date: d.toISOString().split('T')[0], units: parseFloat(estUnits.toFixed(1)), revenue: parseFloat(estRevenue.toFixed(2)), confidence: 0, // lower confidence for extrapolated data estimated: true, }); totalRevenue += estRevenue; totalUnits += estUnits; } } // Lifecycle phase breakdown (from actual forecast data only) const { rows: phaseRows } = await executeQuery(` SELECT pf.lifecycle_phase AS phase, COUNT(DISTINCT pf.pid) AS products, COALESCE(SUM(pf.forecast_units), 0) AS units, COALESCE(SUM(pf.forecast_revenue), 0) AS revenue FROM product_forecasts pf JOIN product_metrics pm ON pm.pid = pf.pid WHERE pm.is_visible = true AND pf.forecast_date BETWEEN $1 AND $2 GROUP BY pf.lifecycle_phase ORDER BY revenue DESC `, [startISO, clampedEndISO]); const phaseTotal = phaseRows.reduce((s, r) => s + (parseFloat(r.revenue) || 0), 0); const phaseBreakdown = phaseRows .filter(r => parseFloat(r.revenue) > 0) .map(r => ({ phase: r.phase, products: parseInt(r.products) || 0, units: Math.round(parseFloat(r.units) || 0), revenue: parseFloat(parseFloat(r.revenue).toFixed(2)), percentage: phaseTotal > 0 ? parseFloat(((parseFloat(r.revenue) / phaseTotal) * 100).toFixed(1)) : 0, })); // Category breakdown (from actual forecast data only) const { rows: categoryRows } = await executeQuery(` WITH product_root_category AS ( SELECT DISTINCT ON (pf.pid) pf.pid, ch.name AS category FROM product_forecasts pf JOIN product_metrics pm ON pm.pid = pf.pid JOIN product_categories pc ON pc.pid = pf.pid JOIN category_hierarchy ch ON ch.cat_id = pc.cat_id AND ch.level = 0 WHERE pm.is_visible = true AND ch.name NOT IN ('Deals', 'Black Friday') AND pf.forecast_date BETWEEN $1 AND $2 ORDER BY pf.pid, ch.name ) SELECT prc.category, SUM(pf.forecast_units) AS units, SUM(pf.forecast_revenue) AS revenue FROM product_forecasts pf JOIN product_root_category prc ON prc.pid = pf.pid WHERE pf.forecast_date BETWEEN $1 AND $2 GROUP BY prc.category ORDER BY revenue DESC LIMIT 8 `, [startISO, clampedEndISO]); const dailyForecastsByPhase = dailyPhaseRows.map(d => ({ date: d.date instanceof Date ? d.date.toISOString().split('T')[0] : d.date, preorder: parseFloat(d.preorder) || 0, launch: parseFloat(d.launch) || 0, decay: parseFloat(d.decay) || 0, mature: parseFloat(d.mature) || 0, slow_mover: parseFloat(d.slow_mover) || 0, dormant: parseFloat(d.dormant) || 0, })); // Add extrapolated days to phase series (distribute proportionally using last phase ratios) if (needsExtrapolation && dailyForecastsByPhase.length > 0) { const lastPhaseDay = dailyForecastsByPhase[dailyForecastsByPhase.length - 1]; const phases = ['preorder', 'launch', 'decay', 'mature', 'slow_mover', 'dormant']; const lastTotal = phases.reduce((s, p) => s + lastPhaseDay[p], 0); const phaseRatios = {}; for (const p of phases) { phaseRatios[p] = lastTotal > 0 ? lastPhaseDay[p] / lastTotal : 1 / phases.length; } // Match extrapolated days from dailyForecasts for (let i = dailyForecastsByPhase.length; i < dailyForecasts.length; i++) { const dayRev = dailyForecasts[i].revenue; const entry = { date: dailyForecasts[i].date }; for (const p of phases) { entry[p] = parseFloat((dayRev * phaseRatios[p]).toFixed(2)); } dailyForecastsByPhase.push(entry); } } return res.json({ forecastSales: Math.round(totalUnits), forecastRevenue: parseFloat(totalRevenue.toFixed(2)), confidenceLevel, dailyForecasts, dailyForecastsByPhase, phaseBreakdown, categoryForecasts: categoryRows.map(c => ({ category: c.category, units: Math.round(parseFloat(c.units)), revenue: parseFloat(parseFloat(c.revenue).toFixed(2)), })), }); } // --- Fallback: velocity-based projection (no forecast data yet) --- const { rows: [totals] } = await executeQuery(` SELECT COALESCE(SUM(sales_velocity_daily), 0) AS daily_units, COALESCE(SUM(sales_velocity_daily * current_price), 0) AS daily_revenue, COUNT(*) FILTER (WHERE sales_velocity_daily > 0) AS active_products FROM product_metrics WHERE is_visible = true AND sales_velocity_daily > 0 `); const dailyUnits = parseFloat(totals.daily_units) || 0; const dailyRevenue = parseFloat(totals.daily_revenue) || 0; const dailyForecasts = []; for (let i = 0; i < days; i++) { const d = new Date(startDate); d.setDate(startDate.getDate() + i); dailyForecasts.push({ date: d.toISOString().split('T')[0], units: parseFloat(dailyUnits.toFixed(1)), revenue: parseFloat(dailyRevenue.toFixed(2)), confidence: 0, }); } const { rows: categoryRows } = await executeQuery(` WITH product_root_category AS ( SELECT DISTINCT ON (pm.pid) pm.pid, pm.sales_velocity_daily, pm.current_price, ch.name AS category FROM product_metrics pm JOIN product_categories pc ON pc.pid = pm.pid JOIN category_hierarchy ch ON ch.cat_id = pc.cat_id AND ch.level = 0 WHERE pm.is_visible = true AND pm.sales_velocity_daily > 0 AND ch.name NOT IN ('Deals', 'Black Friday') ORDER BY pm.pid, ch.name ) SELECT category, ROUND(SUM(sales_velocity_daily)::numeric, 1) AS daily_units, ROUND(SUM(sales_velocity_daily * current_price)::numeric, 2) AS daily_revenue FROM product_root_category GROUP BY category ORDER BY daily_revenue DESC LIMIT 8 `); res.json({ forecastSales: Math.round(dailyUnits * days), forecastRevenue: parseFloat((dailyRevenue * days).toFixed(2)), confidenceLevel: 0, dailyForecasts, categoryForecasts: categoryRows.map(c => ({ category: c.category, units: Math.round(parseFloat(c.daily_units) * days), revenue: parseFloat((parseFloat(c.daily_revenue) * days).toFixed(2)), })), }); } catch (err) { console.error('Error fetching forecast metrics:', err); res.status(500).json({ error: 'Failed to fetch forecast metrics' }); } }); // GET /dashboard/forecast/accuracy // Returns forecast accuracy metrics computed by the forecast engine. // Reads from forecast_accuracy table (populated after each forecast run). router.get('/forecast/accuracy', async (req, res) => { try { // Check if forecast_accuracy table exists and has data const { rows: [tableCheck] } = await executeQuery(` SELECT EXISTS ( SELECT 1 FROM information_schema.tables WHERE table_name = 'forecast_accuracy' ) AS exists `); if (!tableCheck.exists) { return res.json({ hasData: false, message: 'Accuracy data not yet available' }); } // Get the latest run that has accuracy data const { rows: runRows } = await executeQuery(` SELECT DISTINCT fa.run_id, fr.finished_at FROM forecast_accuracy fa JOIN forecast_runs fr ON fr.id = fa.run_id ORDER BY fr.finished_at DESC LIMIT 1 `); if (runRows.length === 0) { return res.json({ hasData: false, message: 'No accuracy data computed yet' }); } const latestRunId = runRows[0].run_id; const computedAt = runRows[0].finished_at; // Count days of history available const { rows: [historyInfo] } = await executeQuery(` SELECT COUNT(DISTINCT forecast_date) AS days_of_history, MIN(forecast_date) AS earliest_date, MAX(forecast_date) AS latest_date FROM product_forecasts_history `); // Fetch all accuracy metrics for the latest run const { rows: metrics } = await executeQuery(` SELECT metric_type, dimension_value, sample_size, total_actual_units, total_forecast_units, mae, wmape, bias, rmse FROM forecast_accuracy WHERE run_id = $1 ORDER BY metric_type, dimension_value `, [latestRunId]); // Organize into response structure const overall = metrics.find(m => m.metric_type === 'overall'); const byPhase = metrics .filter(m => m.metric_type === 'by_phase') .map(m => ({ phase: m.dimension_value, sampleSize: parseInt(m.sample_size), totalActual: parseFloat(m.total_actual_units) || 0, totalForecast: parseFloat(m.total_forecast_units) || 0, mae: m.mae != null ? parseFloat(parseFloat(m.mae).toFixed(4)) : null, wmape: m.wmape != null ? parseFloat((parseFloat(m.wmape) * 100).toFixed(1)) : null, bias: m.bias != null ? parseFloat(parseFloat(m.bias).toFixed(4)) : null, rmse: m.rmse != null ? parseFloat(parseFloat(m.rmse).toFixed(4)) : null, })) .sort((a, b) => (b.totalActual || 0) - (a.totalActual || 0)); const byLeadTime = metrics .filter(m => m.metric_type === 'by_lead_time') .map(m => ({ bucket: m.dimension_value, sampleSize: parseInt(m.sample_size), mae: m.mae != null ? parseFloat(parseFloat(m.mae).toFixed(4)) : null, wmape: m.wmape != null ? parseFloat((parseFloat(m.wmape) * 100).toFixed(1)) : null, bias: m.bias != null ? parseFloat(parseFloat(m.bias).toFixed(4)) : null, rmse: m.rmse != null ? parseFloat(parseFloat(m.rmse).toFixed(4)) : null, })) .sort((a, b) => { const order = { '1-7d': 0, '8-14d': 1, '15-30d': 2, '31-60d': 3, '61-90d': 4 }; return (order[a.bucket] ?? 99) - (order[b.bucket] ?? 99); }); const byMethod = metrics .filter(m => m.metric_type === 'by_method') .map(m => ({ method: m.dimension_value, sampleSize: parseInt(m.sample_size), mae: m.mae != null ? parseFloat(parseFloat(m.mae).toFixed(4)) : null, wmape: m.wmape != null ? parseFloat((parseFloat(m.wmape) * 100).toFixed(1)) : null, bias: m.bias != null ? parseFloat(parseFloat(m.bias).toFixed(4)) : null, })); const dailyTrend = metrics .filter(m => m.metric_type === 'daily') .map(m => ({ date: m.dimension_value, mae: m.mae != null ? parseFloat(parseFloat(m.mae).toFixed(4)) : null, wmape: m.wmape != null ? parseFloat((parseFloat(m.wmape) * 100).toFixed(1)) : null, bias: m.bias != null ? parseFloat(parseFloat(m.bias).toFixed(4)) : null, })) .sort((a, b) => a.date.localeCompare(b.date)); // Historical accuracy trend (across runs) const { rows: trendRows } = await executeQuery(` SELECT fa.run_id, fr.finished_at::date AS run_date, fa.mae, fa.wmape, fa.bias, fa.rmse, fa.sample_size FROM forecast_accuracy fa JOIN forecast_runs fr ON fr.id = fa.run_id WHERE fa.metric_type = 'overall' AND fa.dimension_value = 'all' ORDER BY fr.finished_at `); const accuracyTrend = trendRows.map(r => ({ date: r.run_date instanceof Date ? r.run_date.toISOString().split('T')[0] : r.run_date, mae: r.mae != null ? parseFloat(parseFloat(r.mae).toFixed(4)) : null, wmape: r.wmape != null ? parseFloat((parseFloat(r.wmape) * 100).toFixed(1)) : null, bias: r.bias != null ? parseFloat(parseFloat(r.bias).toFixed(4)) : null, sampleSize: parseInt(r.sample_size), })); res.json({ hasData: true, computedAt, daysOfHistory: parseInt(historyInfo.days_of_history) || 0, historyRange: { from: historyInfo.earliest_date instanceof Date ? historyInfo.earliest_date.toISOString().split('T')[0] : historyInfo.earliest_date, to: historyInfo.latest_date instanceof Date ? historyInfo.latest_date.toISOString().split('T')[0] : historyInfo.latest_date, }, overall: overall ? { sampleSize: parseInt(overall.sample_size), totalActual: parseFloat(overall.total_actual_units) || 0, totalForecast: parseFloat(overall.total_forecast_units) || 0, mae: overall.mae != null ? parseFloat(parseFloat(overall.mae).toFixed(4)) : null, wmape: overall.wmape != null ? parseFloat((parseFloat(overall.wmape) * 100).toFixed(1)) : null, bias: overall.bias != null ? parseFloat(parseFloat(overall.bias).toFixed(4)) : null, rmse: overall.rmse != null ? parseFloat(parseFloat(overall.rmse).toFixed(4)) : null, } : null, byPhase, byLeadTime, byMethod, dailyTrend, accuracyTrend, }); } catch (err) { console.error('Error fetching forecast accuracy:', err); res.status(500).json({ error: 'Failed to fetch forecast accuracy' }); } }); // GET /dashboard/overstock/metrics // Returns overstock metrics by category router.get('/overstock/metrics', async (req, res) => { try { // Check if we have any products with Overstock status const { rows: [countCheck] } = await executeQuery(` SELECT COUNT(*) as overstock_count FROM product_metrics WHERE status = 'Overstock' AND is_visible = true `); // If no overstock products, return empty metrics if (parseInt(countCheck.overstock_count) === 0) { return res.json({ overstockedProducts: 0, totalExcessUnits: 0, totalExcessCost: 0, totalExcessRetail: 0, categoryData: [] }); } // Get summary metrics in a simpler, more direct query const { rows: [summaryMetrics] } = await executeQuery(` SELECT COUNT(DISTINCT pid)::integer as total_overstocked, SUM(overstocked_units)::integer as total_excess_units, ROUND(SUM(overstocked_cost)::numeric, 2) as total_excess_cost, ROUND(SUM(overstocked_retail)::numeric, 2) as total_excess_retail FROM product_metrics WHERE status = 'Overstock' AND is_visible = true `); // Get category breakdowns separately const { rows: categoryData } = await executeQuery(` SELECT c.name as category_name, COUNT(DISTINCT pm.pid)::integer as overstocked_products, SUM(pm.overstocked_units)::integer as total_excess_units, ROUND(SUM(pm.overstocked_cost)::numeric, 2) as total_excess_cost, ROUND(SUM(pm.overstocked_retail)::numeric, 2) as total_excess_retail FROM categories c JOIN product_categories pc ON c.cat_id = pc.cat_id JOIN product_metrics pm ON pc.pid = pm.pid WHERE pm.status = 'Overstock' AND pm.is_visible = true GROUP BY c.name ORDER BY total_excess_cost DESC LIMIT 8 `); // Overstock breakdown by lifecycle phase const { rows: phaseOverstock } = await executeQuery(` SELECT COALESCE(pm.lifecycle_phase, 'unknown') AS phase, COUNT(DISTINCT pm.pid)::integer AS products, COALESCE(SUM(pm.overstocked_units), 0)::integer AS units, ROUND(COALESCE(SUM(pm.overstocked_cost), 0)::numeric, 2) AS cost, ROUND(COALESCE(SUM(pm.overstocked_retail), 0)::numeric, 2) AS retail FROM product_metrics pm WHERE pm.status = 'Overstock' AND pm.is_visible = true AND COALESCE(pm.preorder_count, 0) = 0 GROUP BY pm.lifecycle_phase ORDER BY cost DESC `); const overstockPhaseTotalCost = phaseOverstock.reduce((s, r) => s + (parseFloat(r.cost) || 0), 0); // Format response with explicit type conversion const response = { overstockedProducts: parseInt(summaryMetrics.total_overstocked) || 0, totalExcessUnits: parseInt(summaryMetrics.total_excess_units) || 0, totalExcessCost: parseFloat(summaryMetrics.total_excess_cost) || 0, totalExcessRetail: parseFloat(summaryMetrics.total_excess_retail) || 0, categoryData: categoryData.map(cat => ({ category: cat.category_name, products: parseInt(cat.overstocked_products) || 0, units: parseInt(cat.total_excess_units) || 0, cost: parseFloat(cat.total_excess_cost) || 0, retail: parseFloat(cat.total_excess_retail) || 0 })), phaseBreakdown: phaseOverstock.filter(r => parseFloat(r.cost) > 0).map(r => ({ phase: r.phase, products: parseInt(r.products) || 0, units: parseInt(r.units) || 0, cost: parseFloat(r.cost) || 0, retail: parseFloat(r.retail) || 0, percentage: overstockPhaseTotalCost > 0 ? parseFloat(((parseFloat(r.cost) / overstockPhaseTotalCost) * 100).toFixed(1)) : 0, })), }; res.json(response); } catch (err) { console.error('Error fetching overstock metrics:', err); res.status(500).json({ error: 'Failed to fetch overstock metrics' }); } }); // GET /dashboard/overstock/products // Returns list of most overstocked products router.get('/overstock/products', async (req, res) => { const limit = parseInt(req.query.limit) || 50; try { const { rows } = await executeQuery(` SELECT pm.pid, pm.sku AS SKU, pm.title, pm.brand, pm.vendor, pm.current_stock as stock_quantity, pm.current_cost_price as cost_price, pm.current_price as price, pm.sales_velocity_daily as daily_sales_avg, pm.stock_cover_in_days as days_of_inventory, pm.overstocked_units as overstocked_amt, pm.overstocked_cost as excess_cost, pm.overstocked_retail as excess_retail, STRING_AGG(c.name, ', ') as categories FROM product_metrics pm LEFT JOIN product_categories pc ON pm.pid = pc.pid LEFT JOIN categories c ON pc.cat_id = c.cat_id WHERE pm.status = 'Overstock' AND pm.is_visible = true GROUP BY pm.pid, pm.sku, pm.title, pm.brand, pm.vendor, pm.current_stock, pm.current_cost_price, pm.current_price, pm.sales_velocity_daily, pm.stock_cover_in_days, pm.overstocked_units, pm.overstocked_cost, pm.overstocked_retail ORDER BY excess_cost DESC LIMIT $1 `, [limit]); res.json(rows); } catch (err) { console.error('Error fetching overstocked products:', err); res.status(500).json({ error: 'Failed to fetch overstocked products' }); } }); // GET /dashboard/best-sellers // Returns best-selling products, brands, and categories (from product_metrics) router.get('/best-sellers', async (req, res) => { try { // Best selling products const { rows: products } = await executeQuery(` SELECT pm.pid, pm.sku, pm.title, pm.sales_30d::integer as units_sold, ROUND(pm.revenue_30d::numeric, 2) as revenue, ROUND(pm.profit_30d::numeric, 2) as profit FROM product_metrics pm WHERE pm.is_visible = true AND pm.sales_30d > 0 ORDER BY pm.sales_30d DESC LIMIT 10 `); // Best selling brands const { rows: brands } = await executeQuery(` SELECT pm.brand, SUM(pm.sales_30d)::integer as units_sold, ROUND(SUM(pm.revenue_30d)::numeric, 2) as revenue, ROUND(SUM(pm.profit_30d)::numeric, 2) as profit, ROUND( CASE WHEN SUM(pm.revenue_30d) > 0 AND COUNT(CASE WHEN pm.sales_growth_30d_vs_prev IS NOT NULL THEN 1 END) > 0 THEN SUM(pm.revenue_30d * COALESCE(pm.sales_growth_30d_vs_prev, 0)) / NULLIF(SUM(CASE WHEN pm.sales_growth_30d_vs_prev IS NOT NULL THEN pm.revenue_30d ELSE 0 END), 0) ELSE NULL END::numeric, 1 ) as growth_rate FROM product_metrics pm WHERE pm.is_visible = true AND pm.sales_30d > 0 GROUP BY pm.brand ORDER BY units_sold DESC LIMIT 10 `); // Best selling categories with full path from materialized view const { rows: categories } = await executeQuery(` SELECT c.cat_id, c.name, ch.path as "categoryPath", SUM(pm.sales_30d)::integer as units_sold, ROUND(SUM(pm.revenue_30d)::numeric, 2) as revenue, ROUND(SUM(pm.profit_30d)::numeric, 2) as profit FROM product_metrics pm JOIN product_categories pc ON pm.pid = pc.pid JOIN categories c ON pc.cat_id = c.cat_id JOIN category_hierarchy ch ON c.cat_id = ch.cat_id WHERE pm.is_visible = true AND pm.sales_30d > 0 GROUP BY c.cat_id, c.name, ch.path ORDER BY units_sold DESC LIMIT 10 `); res.json({ products, brands, categories }); } catch (err) { console.error('Error fetching best sellers:', err); res.status(500).json({ error: 'Failed to fetch best sellers' }); } }); // GET /dashboard/year-revenue-estimate // Returns YTD actual revenue + rest-of-year forecast revenue for a full-year estimate router.get('/year-revenue-estimate', async (req, res) => { const now = new Date(); const yearStart = `${now.getFullYear()}-01-01`; const todayISO = now.toISOString().split('T')[0]; const yearEndISO = `${now.getFullYear()}-12-31`; try { // YTD actual revenue from orders const { rows: [ytd] } = await executeQuery(` SELECT COALESCE(SUM(price * quantity), 0) AS revenue FROM orders WHERE date >= $1 AND date <= $2 AND canceled = false `, [yearStart, todayISO]); // Forecast horizon const { rows: [horizonRow] } = await executeQuery( `SELECT MAX(forecast_date) AS max_date FROM product_forecasts` ); const forecastHorizonISO = horizonRow?.max_date ? (horizonRow.max_date instanceof Date ? horizonRow.max_date.toISOString().split('T')[0] : horizonRow.max_date) : todayISO; const clampedEnd = yearEndISO <= forecastHorizonISO ? yearEndISO : forecastHorizonISO; // Forecast revenue from tomorrow to clamped end const { rows: [forecast] } = await executeQuery(` SELECT COALESCE(SUM(pf.forecast_revenue), 0) AS revenue FROM product_forecasts pf JOIN product_metrics pm ON pm.pid = pf.pid WHERE pm.is_visible = true AND pf.forecast_date > $1 AND pf.forecast_date <= $2 `, [todayISO, clampedEnd]); let eoyForecastRevenue = parseFloat(forecast.revenue) || 0; // If forecast doesn't cover full year, extrapolate remaining days if (yearEndISO > forecastHorizonISO) { const { rows: [tailRow] } = await executeQuery(` SELECT AVG(daily_rev) AS avg_daily FROM ( SELECT forecast_date, SUM(pf.forecast_revenue) AS daily_rev FROM product_forecasts pf JOIN product_metrics pm ON pm.pid = pf.pid WHERE pm.is_visible = true AND pf.forecast_date > ($1::date - INTERVAL '7 days') AND pf.forecast_date <= $1 GROUP BY forecast_date ) sub `, [forecastHorizonISO]); const baselineDaily = parseFloat(tailRow?.avg_daily) || 0; const horizonDate = new Date(forecastHorizonISO + 'T00:00:00'); const yearEnd = new Date(yearEndISO + 'T00:00:00'); const extraDays = Math.round((yearEnd - horizonDate) / (1000 * 60 * 60 * 24)); eoyForecastRevenue += baselineDaily * extraDays; } const ytdRevenue = parseFloat(ytd.revenue) || 0; res.json({ ytdRevenue, eoyForecastRevenue, yearTotal: ytdRevenue + eoyForecastRevenue, }); } catch (err) { console.error('Error fetching year revenue estimate:', err); res.status(500).json({ error: 'Failed to fetch year revenue estimate' }); } }); // GET /dashboard/sales/metrics // Returns sales metrics for specified period router.get('/sales/metrics', async (req, res) => { // Default to last 30 days if no date range provided const today = new Date(); const thirtyDaysAgo = new Date(today); thirtyDaysAgo.setDate(today.getDate() - 30); const startDate = req.query.startDate || thirtyDaysAgo.toISOString(); const endDate = req.query.endDate || today.toISOString(); try { // Get daily orders and totals for the specified period const { rows: dailyRows } = await executeQuery(` SELECT DATE(date) as sale_date, COUNT(DISTINCT order_number) as total_orders, SUM(quantity) as total_units, SUM(price * quantity) as total_revenue, SUM(costeach * quantity) as total_cogs FROM orders WHERE date BETWEEN $1 AND $2 AND canceled = false GROUP BY DATE(date) ORDER BY sale_date `, [startDate, endDate]); // Get overall metrics for the period const { rows: [metrics] } = await executeQuery(` SELECT COUNT(DISTINCT order_number) as total_orders, SUM(quantity) as total_units, SUM(price * quantity) as total_revenue, SUM(costeach * quantity) as total_cogs FROM orders WHERE date BETWEEN $1 AND $2 AND canceled = false `, [startDate, endDate]); // Sales breakdown by lifecycle phase const { rows: phaseSales } = await executeQuery(` SELECT COALESCE(pm.lifecycle_phase, 'unknown') AS phase, COUNT(DISTINCT o.order_number)::integer AS orders, COALESCE(SUM(o.quantity), 0)::integer AS units, ROUND(COALESCE(SUM(o.price * o.quantity), 0)::numeric, 2) AS revenue, ROUND(COALESCE(SUM(o.costeach * o.quantity), 0)::numeric, 2) AS cogs FROM orders o LEFT JOIN product_metrics pm ON o.pid = pm.pid WHERE o.date BETWEEN $1 AND $2 AND o.canceled = false GROUP BY pm.lifecycle_phase ORDER BY revenue DESC `, [startDate, endDate]); const salePhaseTotalRev = phaseSales.reduce((s, r) => s + (parseFloat(r.revenue) || 0), 0); // Daily sales broken down by lifecycle phase (for stacked chart) const { rows: dailyPhaseRows } = await executeQuery(` SELECT DATE(o.date) AS sale_date, COALESCE(SUM(o.price * o.quantity) FILTER (WHERE COALESCE(pm.lifecycle_phase, 'unknown') = 'preorder'), 0) AS preorder, COALESCE(SUM(o.price * o.quantity) FILTER (WHERE COALESCE(pm.lifecycle_phase, 'unknown') = 'launch'), 0) AS launch, COALESCE(SUM(o.price * o.quantity) FILTER (WHERE COALESCE(pm.lifecycle_phase, 'unknown') = 'decay'), 0) AS decay, COALESCE(SUM(o.price * o.quantity) FILTER (WHERE COALESCE(pm.lifecycle_phase, 'unknown') = 'mature'), 0) AS mature, COALESCE(SUM(o.price * o.quantity) FILTER (WHERE COALESCE(pm.lifecycle_phase, 'unknown') = 'slow_mover'), 0) AS slow_mover, COALESCE(SUM(o.price * o.quantity) FILTER (WHERE COALESCE(pm.lifecycle_phase, 'unknown') = 'dormant'), 0) AS dormant, COALESCE(SUM(o.price * o.quantity) FILTER (WHERE pm.lifecycle_phase IS NULL), 0) AS unknown FROM orders o LEFT JOIN product_metrics pm ON o.pid = pm.pid WHERE o.date BETWEEN $1 AND $2 AND o.canceled = false GROUP BY DATE(o.date) ORDER BY sale_date `, [startDate, endDate]); const response = { totalOrders: parseInt(metrics?.total_orders) || 0, totalUnitsSold: parseInt(metrics?.total_units) || 0, totalCogs: parseFloat(metrics?.total_cogs) || 0, totalRevenue: parseFloat(metrics?.total_revenue) || 0, dailySales: dailyRows.map(day => ({ date: day.sale_date, units: parseInt(day.total_units) || 0, revenue: parseFloat(day.total_revenue) || 0, cogs: parseFloat(day.total_cogs) || 0 })), dailySalesByPhase: dailyPhaseRows.map(d => ({ date: d.sale_date, preorder: parseFloat(d.preorder) || 0, launch: parseFloat(d.launch) || 0, decay: parseFloat(d.decay) || 0, mature: parseFloat(d.mature) || 0, slow_mover: parseFloat(d.slow_mover) || 0, dormant: parseFloat(d.dormant) || 0, unknown: parseFloat(d.unknown) || 0, })), phaseBreakdown: phaseSales.filter(r => parseFloat(r.revenue) > 0).map(r => ({ phase: r.phase, orders: parseInt(r.orders) || 0, units: parseInt(r.units) || 0, revenue: parseFloat(r.revenue) || 0, cogs: parseFloat(r.cogs) || 0, percentage: salePhaseTotalRev > 0 ? parseFloat(((parseFloat(r.revenue) / salePhaseTotalRev) * 100).toFixed(1)) : 0, })), }; res.json(response); } catch (err) { console.error('Error fetching sales metrics:', err); res.status(500).json({ error: 'Failed to fetch sales metrics' }); } }); // GET /dashboard/vendor/performance // Returns detailed vendor performance metrics router.get('/vendor/performance', async (req, res) => { try { const { rows } = await executeQuery(` WITH stale AS ( SELECT po_id, pid FROM purchase_orders po WHERE po.status IN ('created', 'ordered', 'preordered', 'electronically_sent', 'electronically_ready_send', 'receiving_started') AND po.expected_date IS NOT NULL AND po.expected_date < CURRENT_DATE - INTERVAL '90 days' AND EXISTS ( SELECT 1 FROM purchase_orders newer WHERE newer.pid = po.pid AND newer.status NOT IN ('canceled', 'done') AND COALESCE(newer.date_ordered, newer.date_created) > COALESCE(po.date_ordered, po.date_created) ) ), vendor_orders AS ( SELECT po.vendor, COUNT(DISTINCT po.po_id)::integer as total_orders, COALESCE(ROUND(AVG(CASE WHEN po.received_date IS NOT NULL THEN EXTRACT(EPOCH FROM (po.received_date - po.date))/86400 ELSE NULL END)::numeric, 2), 0) as avg_lead_time, COALESCE(ROUND(SUM(CASE WHEN po.status = 'done' AND po.received_date <= po.expected_date THEN 1 ELSE 0 END)::numeric * 100.0 / NULLIF(COUNT(*)::numeric, 0), 2), 0) as on_time_delivery_rate, COALESCE(ROUND(AVG(CASE WHEN po.status = 'done' THEN po.received::numeric / NULLIF(po.ordered::numeric, 0) * 100 ELSE NULL END)::numeric, 2), 0) as avg_fill_rate, COUNT(CASE WHEN po.status IN ('created', 'electronically_ready_send', 'ordered', 'preordered', 'electronically_sent', 'receiving_started') AND NOT EXISTS (SELECT 1 FROM stale s WHERE s.po_id = po.po_id AND s.pid = po.pid) THEN 1 END)::integer as active_orders, COUNT(CASE WHEN po.status IN ('created', 'electronically_ready_send', 'ordered', 'preordered', 'electronically_sent', 'receiving_started') AND po.expected_date < CURRENT_DATE AND NOT EXISTS (SELECT 1 FROM stale s WHERE s.po_id = po.po_id AND s.pid = po.pid) THEN 1 END)::integer as overdue_orders FROM purchase_orders po WHERE po.date >= CURRENT_DATE - INTERVAL '180 days' GROUP BY po.vendor ) SELECT vo.vendor, vo.total_orders, vo.avg_lead_time, vo.on_time_delivery_rate, vo.avg_fill_rate, vo.active_orders, vo.overdue_orders FROM vendor_orders vo ORDER BY vo.on_time_delivery_rate DESC LIMIT 10 `); const formattedData = rows.map(row => ({ vendor: row.vendor, total_orders: Number(row.total_orders) || 0, avg_lead_time: Number(row.avg_lead_time) || 0, on_time_delivery_rate: Number(row.on_time_delivery_rate) || 0, avg_fill_rate: Number(row.avg_fill_rate) || 0, active_orders: Number(row.active_orders) || 0, overdue_orders: Number(row.overdue_orders) || 0 })); res.json(formattedData); } catch (err) { console.error('Error fetching vendor performance:', err); res.status(500).json({ error: 'Failed to fetch vendor performance' }); } }); // GET /dashboard/replenish/products // Returns list of products to replenish router.get('/replenish/products', async (req, res) => { const limit = parseInt(req.query.limit) || 50; try { const { rows } = await executeQuery(` SELECT pm.pid, pm.sku, pm.title, pm.current_stock AS stock_quantity, pm.sales_velocity_daily AS daily_sales_avg, pm.replenishment_units AS reorder_qty, pm.date_last_received AS last_purchase_date FROM product_metrics pm WHERE pm.is_visible = true AND pm.is_replenishable = true AND (pm.status IN ('Critical', 'Reorder') OR pm.current_stock < 0) AND pm.replenishment_units > 0 ORDER BY CASE pm.status WHEN 'Critical' THEN 1 WHEN 'Reorder' THEN 2 END, pm.replenishment_cost DESC LIMIT $1 `, [limit]); res.json(rows); } catch (err) { console.error('Error fetching products to replenish:', err); res.status(500).json({ error: 'Failed to fetch products to replenish' }); } }); export default router;