1291 lines
59 KiB
JavaScript
1291 lines
59 KiB
JavaScript
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; |