Clean up inventory overview page

This commit is contained in:
2026-02-09 22:59:34 -05:00
parent 6834a77a80
commit f41b5ab0f6
19 changed files with 1064 additions and 1542 deletions

View File

@@ -171,30 +171,37 @@ router.get('/inventory-summary', async (req, res) => {
const pool = req.app.locals.pool;
const { rows: [summary] } = await pool.query(`
SELECT
SUM(current_stock_cost) AS stock_investment,
SUM(on_order_cost) AS on_order_value,
CASE
WHEN SUM(avg_stock_cost_30d) > 0
THEN (SUM(cogs_30d) / SUM(avg_stock_cost_30d)) * 12
ELSE 0
END AS inventory_turns_annualized,
CASE
WHEN SUM(avg_stock_cost_30d) > 0
THEN (SUM(profit_30d) / SUM(avg_stock_cost_30d)) * 12
ELSE 0
END AS gmroi,
CASE
WHEN SUM(CASE WHEN sales_velocity_daily > 0 THEN 1 ELSE 0 END) > 0
THEN SUM(CASE WHEN sales_velocity_daily > 0 THEN stock_cover_in_days ELSE 0 END)
/ SUM(CASE WHEN sales_velocity_daily > 0 THEN 1 ELSE 0 END)
ELSE 0
END AS avg_stock_cover_days,
COUNT(*) FILTER (WHERE current_stock > 0) AS products_in_stock,
COUNT(*) FILTER (WHERE is_old_stock = true) AS dead_stock_products,
SUM(CASE WHEN is_old_stock = true THEN current_stock_cost ELSE 0 END) AS dead_stock_value
FROM product_metrics
WHERE is_visible = true
WITH agg AS (
SELECT
SUM(current_stock_cost) AS stock_investment,
SUM(on_order_cost) AS on_order_value,
CASE
WHEN SUM(avg_stock_cost_30d) > 0
THEN (SUM(cogs_30d) / SUM(avg_stock_cost_30d)) * 12
ELSE 0
END AS inventory_turns_annualized,
CASE
WHEN SUM(avg_stock_cost_30d) > 0
THEN (SUM(profit_30d) / SUM(avg_stock_cost_30d)) * 12
ELSE 0
END AS gmroi,
COUNT(*) FILTER (WHERE current_stock > 0) AS products_in_stock,
COUNT(*) FILTER (WHERE is_old_stock = true AND current_stock > 0) AS dead_stock_products,
SUM(CASE WHEN is_old_stock = true AND current_stock > 0 THEN current_stock_cost ELSE 0 END) AS dead_stock_value
FROM product_metrics
WHERE is_visible = true
),
cover AS (
SELECT
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY stock_cover_in_days) AS median_stock_cover_days
FROM product_metrics
WHERE is_visible = true
AND current_stock > 0
AND sales_velocity_daily > 0
AND stock_cover_in_days IS NOT NULL
)
SELECT agg.*, cover.median_stock_cover_days
FROM agg, cover
`);
res.json({
@@ -202,7 +209,7 @@ router.get('/inventory-summary', async (req, res) => {
onOrderValue: Number(summary.on_order_value) || 0,
inventoryTurns: Number(summary.inventory_turns_annualized) || 0,
gmroi: Number(summary.gmroi) || 0,
avgStockCoverDays: Number(summary.avg_stock_cover_days) || 0,
avgStockCoverDays: Number(summary.median_stock_cover_days) || 0,
productsInStock: Number(summary.products_in_stock) || 0,
deadStockProducts: Number(summary.dead_stock_products) || 0,
deadStockValue: Number(summary.dead_stock_value) || 0,
@@ -266,9 +273,9 @@ router.get('/portfolio', async (req, res) => {
// Dead stock and overstock summary
const { rows: [stockIssues] } = await pool.query(`
SELECT
COUNT(*) FILTER (WHERE is_old_stock = true) AS dead_stock_count,
SUM(CASE WHEN is_old_stock = true THEN current_stock_cost ELSE 0 END) AS dead_stock_cost,
SUM(CASE WHEN is_old_stock = true THEN current_stock_retail ELSE 0 END) AS dead_stock_retail,
COUNT(*) FILTER (WHERE is_old_stock = true AND current_stock > 0) AS dead_stock_count,
SUM(CASE WHEN is_old_stock = true AND current_stock > 0 THEN current_stock_cost ELSE 0 END) AS dead_stock_cost,
SUM(CASE WHEN is_old_stock = true AND current_stock > 0 THEN current_stock_retail ELSE 0 END) AS dead_stock_retail,
COUNT(*) FILTER (WHERE overstocked_units > 0) AS overstock_count,
SUM(COALESCE(overstocked_cost, 0)) AS overstock_cost,
SUM(COALESCE(overstocked_retail, 0)) AS overstock_retail
@@ -300,14 +307,14 @@ router.get('/portfolio', async (req, res) => {
}
});
// Capital efficiency — GMROI by vendor (single combined query)
// Capital efficiency — GMROI by brand (single combined query)
router.get('/efficiency', async (req, res) => {
try {
const pool = req.app.locals.pool;
const { rows } = await pool.query(`
SELECT
vendor AS vendor_name,
COALESCE(brand, 'Unbranded') AS brand_name,
COUNT(*) AS product_count,
SUM(current_stock_cost) AS stock_cost,
SUM(profit_30d) AS profit_30d,
@@ -319,17 +326,17 @@ router.get('/efficiency', async (req, res) => {
END AS gmroi
FROM product_metrics
WHERE is_visible = true
AND vendor IS NOT NULL
AND brand IS NOT NULL
AND current_stock_cost > 0
GROUP BY vendor
GROUP BY brand
HAVING SUM(current_stock_cost) > 100
ORDER BY SUM(current_stock_cost) DESC
LIMIT 30
`);
res.json({
vendors: rows.map(r => ({
vendor: r.vendor_name,
brands: rows.map(r => ({
brand: r.brand_name,
productCount: Number(r.product_count) || 0,
stockCost: Number(r.stock_cost) || 0,
profit30d: Number(r.profit_30d) || 0,
@@ -527,7 +534,7 @@ router.get('/stockout-risk', async (req, res) => {
const { rows } = await pool.query(`
WITH base AS (
SELECT
title, sku, vendor,
title, sku, brand,
${leadTimeSql} AS lead_time_days,
sells_out_in_days, current_stock, sales_velocity_daily,
revenue_30d, abc_class
@@ -554,7 +561,7 @@ router.get('/stockout-risk', async (req, res) => {
products: rows.map(r => ({
title: r.title,
sku: r.sku,
vendor: r.vendor,
brand: r.brand,
leadTimeDays: Number(r.lead_time_days) || 0,
sellsOutInDays: Number(r.sells_out_in_days) || 0,
currentStock: Number(r.current_stock) || 0,
@@ -624,6 +631,7 @@ router.get('/growth', async (req, res) => {
try {
const pool = req.app.locals.pool;
// ABC breakdown — only "comparable" products (sold in BOTH periods, i.e. growth != -100%)
const { rows } = await pool.query(`
SELECT
COALESCE(abc_class, 'N/A') AS abc_class,
@@ -645,21 +653,39 @@ router.get('/growth', async (req, res) => {
FROM product_metrics
WHERE is_visible = true
AND sales_growth_yoy IS NOT NULL
AND sales_30d > 0
GROUP BY 1, 2, 3
ORDER BY abc_class, sort_order
`);
// Summary stats
// Summary: comparable products (sold in both periods) with revenue-weighted avg
const { rows: [summary] } = await pool.query(`
SELECT
COUNT(*) AS total_with_yoy,
COUNT(*) AS comparable_count,
COUNT(*) FILTER (WHERE sales_growth_yoy > 0) AS growing_count,
COUNT(*) FILTER (WHERE sales_growth_yoy <= 0) AS declining_count,
ROUND(AVG(sales_growth_yoy)::numeric, 1) AS avg_growth,
ROUND(
CASE WHEN SUM(revenue_30d) > 0
THEN SUM(sales_growth_yoy * revenue_30d) / SUM(revenue_30d)
ELSE 0
END::numeric, 1
) AS weighted_avg_growth,
ROUND(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY sales_growth_yoy)::numeric, 1) AS median_growth
FROM product_metrics
WHERE is_visible = true
AND sales_growth_yoy IS NOT NULL
AND sales_30d > 0
`);
// Catalog turnover: new products (selling now, no sales last year) and discontinued (sold last year, not now)
const { rows: [turnover] } = await pool.query(`
SELECT
COUNT(*) FILTER (WHERE sales_growth_yoy IS NULL AND sales_30d > 0 AND age_days < 365) AS new_products,
SUM(revenue_30d) FILTER (WHERE sales_growth_yoy IS NULL AND sales_30d > 0 AND age_days < 365) AS new_product_revenue,
COUNT(*) FILTER (WHERE sales_growth_yoy = -100) AS discontinued,
SUM(current_stock_cost) FILTER (WHERE sales_growth_yoy = -100 AND current_stock > 0) AS discontinued_stock_value
FROM product_metrics
WHERE is_visible = true
`);
res.json({
@@ -671,12 +697,18 @@ router.get('/growth', async (req, res) => {
stockCost: Number(r.stock_cost) || 0,
})),
summary: {
totalWithYoy: Number(summary.total_with_yoy) || 0,
comparableCount: Number(summary.comparable_count) || 0,
growingCount: Number(summary.growing_count) || 0,
decliningCount: Number(summary.declining_count) || 0,
avgGrowth: Number(summary.avg_growth) || 0,
weightedAvgGrowth: Number(summary.weighted_avg_growth) || 0,
medianGrowth: Number(summary.median_growth) || 0,
},
turnover: {
newProducts: Number(turnover.new_products) || 0,
newProductRevenue: Number(turnover.new_product_revenue) || 0,
discontinued: Number(turnover.discontinued) || 0,
discontinuedStockValue: Number(turnover.discontinued_stock_value) || 0,
},
});
} catch (error) {
console.error('Error fetching growth data:', error);

File diff suppressed because it is too large Load Diff

View File

@@ -1190,39 +1190,68 @@ router.get('/pipeline', async (req, res) => {
try {
const pool = req.app.locals.pool;
// Expected arrivals by week (ordered + electronically_sent with expected_date)
// Stale PO filter (reused across queries)
const staleFilter = `
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)
)
)`;
// Expected arrivals by week (excludes stale POs)
const { rows: arrivals } = await pool.query(`
${staleFilter}
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
DATE_TRUNC('week', po.expected_date)::date AS week,
COUNT(DISTINCT po.po_id) AS po_count,
ROUND(SUM(po.po_cost_price * po.ordered)::numeric, 0) AS expected_value,
COUNT(DISTINCT po.vendor) AS vendor_count
FROM purchase_orders po
WHERE po.status IN ('ordered', 'electronically_sent')
AND po.expected_date IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM stale s WHERE s.po_id = po.po_id AND s.pid = po.pid)
GROUP BY 1
ORDER BY 1
`);
// Overdue POs (expected_date in the past)
// Overdue POs (excludes stale)
const { rows: [overdue] } = await pool.query(`
${staleFilter}
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
COUNT(DISTINCT po.po_id) AS po_count,
ROUND(COALESCE(SUM(po.po_cost_price * po.ordered), 0)::numeric, 0) AS total_value
FROM purchase_orders po
WHERE po.status IN ('ordered', 'electronically_sent')
AND po.expected_date IS NOT NULL
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)
`);
// Summary: all open POs
// Summary: on-order value from product_metrics (FIFO-accurate), PO counts from purchase_orders with staleness filter
const { rows: [summary] } = await pool.query(`
${staleFilter}
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')
COUNT(DISTINCT po.po_id) AS total_open_pos,
COUNT(DISTINCT po.vendor) AS vendor_count
FROM purchase_orders po
WHERE po.status IN ('ordered', 'electronically_sent')
AND NOT EXISTS (SELECT 1 FROM stale s WHERE s.po_id = po.po_id AND s.pid = po.pid)
`);
const { rows: [onOrderTotal] } = await pool.query(`
SELECT ROUND(COALESCE(SUM(on_order_cost), 0)::numeric, 0) AS total_on_order_value
FROM product_metrics
WHERE is_visible = true
`);
res.json({
@@ -1238,7 +1267,7 @@ router.get('/pipeline', async (req, res) => {
},
summary: {
totalOpenPOs: Number(summary.total_open_pos) || 0,
totalOnOrderValue: Number(summary.total_on_order_value) || 0,
totalOnOrderValue: Number(onOrderTotal.total_on_order_value) || 0,
vendorCount: Number(summary.vendor_count) || 0,
},
});