Files
inventory/inventory-server/src/routes/dashboard.js
T
2026-05-23 19:38:12 -04:00

1291 lines
59 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;