Updates for new analytics page + add pipeline chart to PO page
This commit is contained in:
@@ -181,7 +181,7 @@ router.get('/inventory-summary', async (req, res) => {
|
||||
END AS inventory_turns_annualized,
|
||||
CASE
|
||||
WHEN SUM(avg_stock_cost_30d) > 0
|
||||
THEN SUM(profit_30d) / SUM(avg_stock_cost_30d)
|
||||
THEN (SUM(profit_30d) / SUM(avg_stock_cost_30d)) * 12
|
||||
ELSE 0
|
||||
END AS gmroi,
|
||||
CASE
|
||||
@@ -314,7 +314,7 @@ router.get('/efficiency', async (req, res) => {
|
||||
SUM(revenue_30d) AS revenue_30d,
|
||||
CASE
|
||||
WHEN SUM(avg_stock_cost_30d) > 0
|
||||
THEN SUM(profit_30d) / SUM(avg_stock_cost_30d)
|
||||
THEN (SUM(profit_30d) / SUM(avg_stock_cost_30d)) * 12
|
||||
ELSE 0
|
||||
END AS gmroi
|
||||
FROM product_metrics
|
||||
@@ -684,4 +684,126 @@ router.get('/growth', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Inventory value over time (uses stock_snapshots — full product coverage)
|
||||
router.get('/inventory-value', async (req, res) => {
|
||||
try {
|
||||
const pool = req.app.locals.pool;
|
||||
const period = parseInt(req.query.period) || 90;
|
||||
const validPeriods = [30, 90, 365];
|
||||
const days = validPeriods.includes(period) ? period : 90;
|
||||
|
||||
const { rows } = await pool.query(`
|
||||
SELECT
|
||||
snapshot_date AS date,
|
||||
ROUND(SUM(stock_value)::numeric, 0) AS total_value,
|
||||
COUNT(DISTINCT pid) AS product_count
|
||||
FROM stock_snapshots
|
||||
WHERE snapshot_date >= CURRENT_DATE - make_interval(days => $1)
|
||||
GROUP BY snapshot_date
|
||||
ORDER BY snapshot_date
|
||||
`, [days]);
|
||||
|
||||
res.json(rows.map(r => ({
|
||||
date: r.date,
|
||||
totalValue: Number(r.total_value) || 0,
|
||||
productCount: Number(r.product_count) || 0,
|
||||
})));
|
||||
} catch (error) {
|
||||
console.error('Error fetching inventory value:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch inventory value' });
|
||||
}
|
||||
});
|
||||
|
||||
// Inventory flow: receiving vs selling per day
|
||||
router.get('/flow', async (req, res) => {
|
||||
try {
|
||||
const pool = req.app.locals.pool;
|
||||
const period = parseInt(req.query.period) || 30;
|
||||
const validPeriods = [30, 90];
|
||||
const days = validPeriods.includes(period) ? period : 30;
|
||||
|
||||
const { rows } = await pool.query(`
|
||||
SELECT
|
||||
snapshot_date AS date,
|
||||
COALESCE(SUM(units_received), 0) AS units_received,
|
||||
ROUND(COALESCE(SUM(cost_received), 0)::numeric, 0) AS cost_received,
|
||||
COALESCE(SUM(units_sold), 0) AS units_sold,
|
||||
ROUND(COALESCE(SUM(cogs), 0)::numeric, 0) AS cogs_sold
|
||||
FROM daily_product_snapshots
|
||||
WHERE snapshot_date >= CURRENT_DATE - make_interval(days => $1)
|
||||
GROUP BY snapshot_date
|
||||
ORDER BY snapshot_date
|
||||
`, [days]);
|
||||
|
||||
res.json(rows.map(r => ({
|
||||
date: r.date,
|
||||
unitsReceived: Number(r.units_received) || 0,
|
||||
costReceived: Number(r.cost_received) || 0,
|
||||
unitsSold: Number(r.units_sold) || 0,
|
||||
cogsSold: Number(r.cogs_sold) || 0,
|
||||
})));
|
||||
} catch (error) {
|
||||
console.error('Error fetching inventory flow:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch inventory flow' });
|
||||
}
|
||||
});
|
||||
|
||||
// Seasonal pattern distribution
|
||||
router.get('/seasonal', async (req, res) => {
|
||||
try {
|
||||
const pool = req.app.locals.pool;
|
||||
|
||||
const { rows: patterns } = await pool.query(`
|
||||
SELECT
|
||||
COALESCE(seasonal_pattern, 'unknown') AS pattern,
|
||||
COUNT(*) AS product_count,
|
||||
SUM(current_stock_cost) AS stock_cost,
|
||||
SUM(revenue_30d) AS revenue
|
||||
FROM product_metrics
|
||||
WHERE is_visible = true
|
||||
AND current_stock > 0
|
||||
GROUP BY seasonal_pattern
|
||||
ORDER BY COUNT(*) DESC
|
||||
`);
|
||||
|
||||
const { rows: peakSeasons } = await pool.query(`
|
||||
SELECT
|
||||
peak_season AS month,
|
||||
COUNT(*) AS product_count,
|
||||
SUM(current_stock_cost) AS stock_cost
|
||||
FROM product_metrics
|
||||
WHERE is_visible = true
|
||||
AND current_stock > 0
|
||||
AND seasonal_pattern IN ('moderate', 'strong')
|
||||
AND peak_season IS NOT NULL
|
||||
GROUP BY peak_season
|
||||
ORDER BY
|
||||
CASE peak_season
|
||||
WHEN 'January' THEN 1 WHEN 'February' THEN 2 WHEN 'March' THEN 3
|
||||
WHEN 'April' THEN 4 WHEN 'May' THEN 5 WHEN 'June' THEN 6
|
||||
WHEN 'July' THEN 7 WHEN 'August' THEN 8 WHEN 'September' THEN 9
|
||||
WHEN 'October' THEN 10 WHEN 'November' THEN 11 WHEN 'December' THEN 12
|
||||
ELSE 13
|
||||
END
|
||||
`);
|
||||
|
||||
res.json({
|
||||
patterns: patterns.map(r => ({
|
||||
pattern: r.pattern,
|
||||
productCount: Number(r.product_count) || 0,
|
||||
stockCost: Number(r.stock_cost) || 0,
|
||||
revenue: Number(r.revenue) || 0,
|
||||
})),
|
||||
peakSeasons: peakSeasons.map(r => ({
|
||||
month: r.month,
|
||||
productCount: Number(r.product_count) || 0,
|
||||
stockCost: Number(r.stock_cost) || 0,
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching seasonal data:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch seasonal data' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -1185,4 +1185,67 @@ router.get('/delivery-metrics', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
// PO Pipeline — expected arrivals timeline + overdue summary
|
||||
router.get('/pipeline', async (req, res) => {
|
||||
try {
|
||||
const pool = req.app.locals.pool;
|
||||
|
||||
// Expected arrivals by week (ordered + electronically_sent with expected_date)
|
||||
const { rows: arrivals } = await pool.query(`
|
||||
SELECT
|
||||
DATE_TRUNC('week', expected_date)::date AS week,
|
||||
COUNT(DISTINCT po_id) AS po_count,
|
||||
ROUND(SUM(po_cost_price * ordered)::numeric, 0) AS expected_value,
|
||||
COUNT(DISTINCT vendor) AS vendor_count
|
||||
FROM purchase_orders
|
||||
WHERE status IN ('ordered', 'electronically_sent')
|
||||
AND expected_date IS NOT NULL
|
||||
GROUP BY 1
|
||||
ORDER BY 1
|
||||
`);
|
||||
|
||||
// Overdue POs (expected_date in the past)
|
||||
const { rows: [overdue] } = await pool.query(`
|
||||
SELECT
|
||||
COUNT(DISTINCT po_id) AS po_count,
|
||||
ROUND(COALESCE(SUM(po_cost_price * ordered), 0)::numeric, 0) AS total_value
|
||||
FROM purchase_orders
|
||||
WHERE status IN ('ordered', 'electronically_sent')
|
||||
AND expected_date IS NOT NULL
|
||||
AND expected_date < CURRENT_DATE
|
||||
`);
|
||||
|
||||
// Summary: all open POs
|
||||
const { rows: [summary] } = await pool.query(`
|
||||
SELECT
|
||||
COUNT(DISTINCT po_id) AS total_open_pos,
|
||||
ROUND(COALESCE(SUM(po_cost_price * ordered), 0)::numeric, 0) AS total_on_order_value,
|
||||
COUNT(DISTINCT vendor) AS vendor_count
|
||||
FROM purchase_orders
|
||||
WHERE status IN ('ordered', 'electronically_sent')
|
||||
`);
|
||||
|
||||
res.json({
|
||||
arrivals: arrivals.map(r => ({
|
||||
week: r.week,
|
||||
poCount: Number(r.po_count) || 0,
|
||||
expectedValue: Number(r.expected_value) || 0,
|
||||
vendorCount: Number(r.vendor_count) || 0,
|
||||
})),
|
||||
overdue: {
|
||||
count: Number(overdue.po_count) || 0,
|
||||
value: Number(overdue.total_value) || 0,
|
||||
},
|
||||
summary: {
|
||||
totalOpenPOs: Number(summary.total_open_pos) || 0,
|
||||
totalOnOrderValue: Number(summary.total_on_order_value) || 0,
|
||||
vendorCount: Number(summary.vendor_count) || 0,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching PO pipeline:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch PO pipeline' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
Reference in New Issue
Block a user