Optimize orders import
This commit is contained in:
@@ -1,687 +1 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
|
||||
// Forecasting: summarize sales for products received in a period by brand
|
||||
router.get('/forecast', async (req, res) => {
|
||||
try {
|
||||
const pool = req.app.locals.pool;
|
||||
|
||||
const brand = (req.query.brand || '').toString();
|
||||
const titleSearch = (req.query.search || req.query.q || '').toString().trim() || null;
|
||||
const startDateStr = req.query.startDate;
|
||||
const endDateStr = req.query.endDate;
|
||||
|
||||
if (!brand) {
|
||||
return res.status(400).json({ error: 'Missing required parameter: brand' });
|
||||
}
|
||||
|
||||
// Default to last 30 days if no dates provided
|
||||
const endDate = endDateStr ? new Date(endDateStr) : new Date();
|
||||
const startDate = startDateStr ? new Date(startDateStr) : new Date(endDate.getTime() - 29 * 24 * 60 * 60 * 1000);
|
||||
|
||||
// Normalize to date boundaries for consistency
|
||||
const startISO = new Date(Date.UTC(startDate.getUTCFullYear(), startDate.getUTCMonth(), startDate.getUTCDate())).toISOString();
|
||||
const endISO = new Date(Date.UTC(endDate.getUTCFullYear(), endDate.getUTCMonth(), endDate.getUTCDate())).toISOString();
|
||||
|
||||
const sql = `
|
||||
WITH params AS (
|
||||
SELECT
|
||||
$1::date AS start_date,
|
||||
$2::date AS end_date,
|
||||
$3::text AS brand,
|
||||
$4::text AS title_search,
|
||||
(($2::date - $1::date) + 1)::int AS days
|
||||
),
|
||||
category_path AS (
|
||||
WITH RECURSIVE cp AS (
|
||||
SELECT c.cat_id, c.name, c.parent_id, c.name::text AS path
|
||||
FROM categories c WHERE c.parent_id IS NULL
|
||||
UNION ALL
|
||||
SELECT c.cat_id, c.name, c.parent_id, (cp.path || ' > ' || c.name)::text
|
||||
FROM categories c
|
||||
JOIN cp ON c.parent_id = cp.cat_id
|
||||
)
|
||||
SELECT * FROM cp
|
||||
),
|
||||
product_first_received AS (
|
||||
SELECT
|
||||
p.pid,
|
||||
COALESCE(p.first_received::date, MIN(r.received_date)::date) AS first_received_date
|
||||
FROM products p
|
||||
LEFT JOIN receivings r ON r.pid = p.pid
|
||||
GROUP BY p.pid, p.first_received
|
||||
),
|
||||
recent_products AS (
|
||||
SELECT p.pid
|
||||
FROM products p
|
||||
JOIN product_first_received fr ON fr.pid = p.pid
|
||||
JOIN params pr ON 1=1
|
||||
WHERE p.visible = true
|
||||
AND COALESCE(p.brand,'Unbranded') = pr.brand
|
||||
AND fr.first_received_date BETWEEN pr.start_date AND pr.end_date
|
||||
AND (pr.title_search IS NULL OR p.title ILIKE '%' || pr.title_search || '%')
|
||||
),
|
||||
product_pick_category AS (
|
||||
(
|
||||
SELECT DISTINCT ON (pc.pid)
|
||||
pc.pid,
|
||||
c.name AS category_name,
|
||||
COALESCE(cp.path, c.name) AS path
|
||||
FROM product_categories pc
|
||||
JOIN categories c ON c.cat_id = pc.cat_id AND (c.type IS NULL OR c.type NOT IN (20,21))
|
||||
LEFT JOIN category_path cp ON cp.cat_id = c.cat_id
|
||||
WHERE pc.pid IN (SELECT pid FROM recent_products)
|
||||
AND (cp.path IS NULL OR (
|
||||
cp.path NOT ILIKE '%Black Friday%'
|
||||
AND cp.path NOT ILIKE '%Deals%'
|
||||
))
|
||||
AND COALESCE(c.name, '') NOT IN ('Black Friday', 'Deals')
|
||||
ORDER BY pc.pid, length(COALESCE(cp.path,'')) DESC
|
||||
)
|
||||
UNION ALL
|
||||
(
|
||||
SELECT
|
||||
rp.pid,
|
||||
'Uncategorized'::text AS category_name,
|
||||
'Uncategorized'::text AS path
|
||||
FROM recent_products rp
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM product_categories pc
|
||||
JOIN categories c ON c.cat_id = pc.cat_id AND (c.type IS NULL OR c.type NOT IN (20,21))
|
||||
LEFT JOIN category_path cp ON cp.cat_id = c.cat_id
|
||||
WHERE pc.pid = rp.pid
|
||||
AND (cp.path IS NULL OR (
|
||||
cp.path NOT ILIKE '%Black Friday%'
|
||||
AND cp.path NOT ILIKE '%Deals%'
|
||||
))
|
||||
AND COALESCE(c.name, '') NOT IN ('Black Friday', 'Deals')
|
||||
)
|
||||
)
|
||||
),
|
||||
product_sales AS (
|
||||
SELECT
|
||||
p.pid,
|
||||
p.title,
|
||||
p.sku,
|
||||
COALESCE(p.stock_quantity, 0) AS stock_quantity,
|
||||
COALESCE(p.price, 0) AS price,
|
||||
COALESCE(SUM(o.quantity), 0) AS total_sold
|
||||
FROM recent_products rp
|
||||
JOIN products p ON p.pid = rp.pid
|
||||
LEFT JOIN params pr ON true
|
||||
LEFT JOIN orders o ON o.pid = p.pid
|
||||
AND o.date::date BETWEEN pr.start_date AND pr.end_date
|
||||
AND (o.canceled IS DISTINCT FROM TRUE)
|
||||
GROUP BY p.pid, p.title, p.sku, p.stock_quantity, p.price
|
||||
)
|
||||
SELECT
|
||||
ppc.category_name,
|
||||
ppc.path,
|
||||
COUNT(ps.pid) AS num_products,
|
||||
SUM(ps.total_sold) AS total_sold,
|
||||
ROUND(AVG(COALESCE(ps.total_sold,0) / NULLIF(pr.days,0)), 2) AS avg_daily_sales,
|
||||
ROUND(AVG(COALESCE(ps.total_sold,0)), 2) AS avg_total_sold,
|
||||
MIN(ps.total_sold) AS min_total_sold,
|
||||
MAX(ps.total_sold) AS max_total_sold,
|
||||
JSON_AGG(
|
||||
JSON_BUILD_OBJECT(
|
||||
'pid', ps.pid,
|
||||
'title', ps.title,
|
||||
'sku', ps.sku,
|
||||
'total_sold', ps.total_sold,
|
||||
'categoryPath', ppc.path
|
||||
)
|
||||
) AS products
|
||||
FROM product_sales ps
|
||||
JOIN product_pick_category ppc ON ppc.pid = ps.pid
|
||||
JOIN params pr ON true
|
||||
GROUP BY ppc.category_name, ppc.path
|
||||
HAVING SUM(ps.total_sold) >= 0
|
||||
ORDER BY (ppc.category_name = 'Uncategorized') ASC, avg_total_sold DESC NULLS LAST
|
||||
LIMIT 200;
|
||||
`;
|
||||
|
||||
const { rows } = await pool.query(sql, [startISO, endISO, brand, titleSearch]);
|
||||
|
||||
const shaped = rows.map(r => ({
|
||||
category_name: r.category_name,
|
||||
path: r.path,
|
||||
avg_daily_sales: Number(r.avg_daily_sales) || 0,
|
||||
total_sold: Number(r.total_sold) || 0,
|
||||
num_products: Number(r.num_products) || 0,
|
||||
avgTotalSold: Number(r.avg_total_sold) || 0,
|
||||
minSold: Number(r.min_total_sold) || 0,
|
||||
maxSold: Number(r.max_total_sold) || 0,
|
||||
products: Array.isArray(r.products) ? r.products : []
|
||||
}));
|
||||
|
||||
res.json(shaped);
|
||||
} catch (error) {
|
||||
console.error('Error fetching forecast data:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch forecast data' });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Inventory Intelligence Endpoints ────────────────────────────────────────
|
||||
|
||||
// Inventory KPI summary cards
|
||||
router.get('/inventory-summary', async (req, res) => {
|
||||
try {
|
||||
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)
|
||||
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
|
||||
`);
|
||||
|
||||
res.json({
|
||||
stockInvestment: Number(summary.stock_investment) || 0,
|
||||
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,
|
||||
productsInStock: Number(summary.products_in_stock) || 0,
|
||||
deadStockProducts: Number(summary.dead_stock_products) || 0,
|
||||
deadStockValue: Number(summary.dead_stock_value) || 0,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching inventory summary:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch inventory summary' });
|
||||
}
|
||||
});
|
||||
|
||||
// Daily sales activity & stockouts over time
|
||||
router.get('/inventory-trends', async (req, res) => {
|
||||
try {
|
||||
const pool = req.app.locals.pool;
|
||||
const period = parseInt(req.query.period) || 30;
|
||||
const validPeriods = [30, 90, 365];
|
||||
const days = validPeriods.includes(period) ? period : 30;
|
||||
|
||||
const { rows } = await pool.query(`
|
||||
SELECT
|
||||
snapshot_date AS date,
|
||||
COUNT(*) FILTER (WHERE stockout_flag = true) AS stockout_count,
|
||||
SUM(units_sold) AS units_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,
|
||||
stockoutCount: Number(r.stockout_count) || 0,
|
||||
unitsSold: Number(r.units_sold) || 0,
|
||||
})));
|
||||
} catch (error) {
|
||||
console.error('Error fetching inventory trends:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch inventory trends' });
|
||||
}
|
||||
});
|
||||
|
||||
// ABC Portfolio analysis
|
||||
router.get('/portfolio', async (req, res) => {
|
||||
try {
|
||||
const pool = req.app.locals.pool;
|
||||
|
||||
// ABC class breakdown
|
||||
const { rows: abcBreakdown } = await pool.query(`
|
||||
SELECT
|
||||
COALESCE(abc_class, 'N/A') AS abc_class,
|
||||
COUNT(*) AS product_count,
|
||||
SUM(revenue_30d) AS revenue,
|
||||
SUM(current_stock_cost) AS stock_cost,
|
||||
SUM(profit_30d) AS profit,
|
||||
SUM(sales_30d) AS units_sold
|
||||
FROM product_metrics
|
||||
WHERE is_visible = true
|
||||
GROUP BY abc_class
|
||||
ORDER BY abc_class
|
||||
`);
|
||||
|
||||
// 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 overstocked_units > 0) AS overstock_count,
|
||||
SUM(COALESCE(overstocked_cost, 0)) AS overstock_cost,
|
||||
SUM(COALESCE(overstocked_retail, 0)) AS overstock_retail
|
||||
FROM product_metrics
|
||||
WHERE is_visible = true
|
||||
`);
|
||||
|
||||
res.json({
|
||||
abcBreakdown: abcBreakdown.map(r => ({
|
||||
abcClass: r.abc_class,
|
||||
productCount: Number(r.product_count) || 0,
|
||||
revenue: Number(r.revenue) || 0,
|
||||
stockCost: Number(r.stock_cost) || 0,
|
||||
profit: Number(r.profit) || 0,
|
||||
unitsSold: Number(r.units_sold) || 0,
|
||||
})),
|
||||
stockIssues: {
|
||||
deadStockCount: Number(stockIssues.dead_stock_count) || 0,
|
||||
deadStockCost: Number(stockIssues.dead_stock_cost) || 0,
|
||||
deadStockRetail: Number(stockIssues.dead_stock_retail) || 0,
|
||||
overstockCount: Number(stockIssues.overstock_count) || 0,
|
||||
overstockCost: Number(stockIssues.overstock_cost) || 0,
|
||||
overstockRetail: Number(stockIssues.overstock_retail) || 0,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching portfolio analysis:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch portfolio analysis' });
|
||||
}
|
||||
});
|
||||
|
||||
// Capital efficiency — GMROI by vendor (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,
|
||||
COUNT(*) AS product_count,
|
||||
SUM(current_stock_cost) AS stock_cost,
|
||||
SUM(profit_30d) AS profit_30d,
|
||||
SUM(revenue_30d) AS revenue_30d,
|
||||
CASE
|
||||
WHEN SUM(avg_stock_cost_30d) > 0
|
||||
THEN SUM(profit_30d) / SUM(avg_stock_cost_30d)
|
||||
ELSE 0
|
||||
END AS gmroi
|
||||
FROM product_metrics
|
||||
WHERE is_visible = true
|
||||
AND vendor IS NOT NULL
|
||||
AND current_stock_cost > 0
|
||||
GROUP BY vendor
|
||||
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,
|
||||
productCount: Number(r.product_count) || 0,
|
||||
stockCost: Number(r.stock_cost) || 0,
|
||||
profit30d: Number(r.profit_30d) || 0,
|
||||
revenue30d: Number(r.revenue_30d) || 0,
|
||||
gmroi: Number(r.gmroi) || 0,
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching capital efficiency:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch capital efficiency' });
|
||||
}
|
||||
});
|
||||
|
||||
// Demand & stock health
|
||||
router.get('/stock-health', async (req, res) => {
|
||||
try {
|
||||
const pool = req.app.locals.pool;
|
||||
|
||||
// Stock cover distribution (histogram buckets)
|
||||
const { rows: coverDistribution } = await pool.query(`
|
||||
SELECT
|
||||
CASE
|
||||
WHEN stock_cover_in_days IS NULL OR stock_cover_in_days <= 0 THEN '0 (Stockout)'
|
||||
WHEN stock_cover_in_days <= 7 THEN '1-7 days'
|
||||
WHEN stock_cover_in_days <= 14 THEN '8-14 days'
|
||||
WHEN stock_cover_in_days <= 30 THEN '15-30 days'
|
||||
WHEN stock_cover_in_days <= 60 THEN '31-60 days'
|
||||
WHEN stock_cover_in_days <= 90 THEN '61-90 days'
|
||||
WHEN stock_cover_in_days <= 180 THEN '91-180 days'
|
||||
ELSE '180+ days'
|
||||
END AS bucket,
|
||||
CASE
|
||||
WHEN stock_cover_in_days IS NULL OR stock_cover_in_days <= 0 THEN 0
|
||||
WHEN stock_cover_in_days <= 7 THEN 1
|
||||
WHEN stock_cover_in_days <= 14 THEN 2
|
||||
WHEN stock_cover_in_days <= 30 THEN 3
|
||||
WHEN stock_cover_in_days <= 60 THEN 4
|
||||
WHEN stock_cover_in_days <= 90 THEN 5
|
||||
WHEN stock_cover_in_days <= 180 THEN 6
|
||||
ELSE 7
|
||||
END AS sort_order,
|
||||
COUNT(*) AS product_count,
|
||||
SUM(current_stock_cost) AS stock_cost
|
||||
FROM product_metrics
|
||||
WHERE is_visible = true
|
||||
AND is_replenishable = true
|
||||
AND sales_30d > 0
|
||||
GROUP BY 1, 2
|
||||
ORDER BY sort_order
|
||||
`);
|
||||
|
||||
// Demand pattern distribution
|
||||
const { rows: demandPatterns } = await pool.query(`
|
||||
SELECT
|
||||
COALESCE(demand_pattern, 'unknown') AS pattern,
|
||||
COUNT(*) AS product_count,
|
||||
SUM(revenue_30d) AS revenue,
|
||||
SUM(current_stock_cost) AS stock_cost
|
||||
FROM product_metrics
|
||||
WHERE is_visible = true
|
||||
AND sales_30d > 0
|
||||
GROUP BY demand_pattern
|
||||
ORDER BY COUNT(*) DESC
|
||||
`);
|
||||
|
||||
// Service level / stockout summary
|
||||
const { rows: [serviceStats] } = await pool.query(`
|
||||
SELECT
|
||||
ROUND(AVG(fill_rate_30d)::numeric, 1) AS avg_fill_rate,
|
||||
ROUND(AVG(service_level_30d)::numeric, 1) AS avg_service_level,
|
||||
SUM(stockout_incidents_30d) AS total_stockout_incidents,
|
||||
SUM(lost_sales_incidents_30d) AS total_lost_sales_incidents,
|
||||
SUM(forecast_lost_sales_units) AS total_lost_units,
|
||||
SUM(forecast_lost_revenue) AS total_lost_revenue,
|
||||
COUNT(*) FILTER (WHERE stockout_days_30d > 0) AS products_with_stockouts,
|
||||
ROUND(AVG(stockout_rate_30d)::numeric, 1) AS avg_stockout_rate
|
||||
FROM product_metrics
|
||||
WHERE is_visible = true
|
||||
AND is_replenishable = true
|
||||
AND sales_30d > 0
|
||||
`);
|
||||
|
||||
res.json({
|
||||
coverDistribution: coverDistribution.map(r => ({
|
||||
bucket: r.bucket,
|
||||
productCount: Number(r.product_count) || 0,
|
||||
stockCost: Number(r.stock_cost) || 0,
|
||||
})),
|
||||
demandPatterns: demandPatterns.map(r => ({
|
||||
pattern: r.pattern,
|
||||
productCount: Number(r.product_count) || 0,
|
||||
revenue: Number(r.revenue) || 0,
|
||||
stockCost: Number(r.stock_cost) || 0,
|
||||
})),
|
||||
serviceStats: {
|
||||
avgFillRate: Number(serviceStats.avg_fill_rate) || 0,
|
||||
avgServiceLevel: Number(serviceStats.avg_service_level) || 0,
|
||||
totalStockoutIncidents: Number(serviceStats.total_stockout_incidents) || 0,
|
||||
totalLostSalesIncidents: Number(serviceStats.total_lost_sales_incidents) || 0,
|
||||
totalLostUnits: Number(serviceStats.total_lost_units) || 0,
|
||||
totalLostRevenue: Number(serviceStats.total_lost_revenue) || 0,
|
||||
productsWithStockouts: Number(serviceStats.products_with_stockouts) || 0,
|
||||
avgStockoutRate: Number(serviceStats.avg_stockout_rate) || 0,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching stock health:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch stock health' });
|
||||
}
|
||||
});
|
||||
|
||||
// Aging & sell-through by age cohort
|
||||
router.get('/aging', async (req, res) => {
|
||||
try {
|
||||
const pool = req.app.locals.pool;
|
||||
|
||||
const { rows } = await pool.query(`
|
||||
SELECT
|
||||
CASE
|
||||
WHEN age_days <= 30 THEN '0-30d'
|
||||
WHEN age_days <= 60 THEN '31-60d'
|
||||
WHEN age_days <= 90 THEN '61-90d'
|
||||
WHEN age_days <= 180 THEN '91-180d'
|
||||
WHEN age_days <= 365 THEN '181-365d'
|
||||
ELSE '365d+'
|
||||
END AS cohort,
|
||||
CASE
|
||||
WHEN age_days <= 30 THEN 0
|
||||
WHEN age_days <= 60 THEN 1
|
||||
WHEN age_days <= 90 THEN 2
|
||||
WHEN age_days <= 180 THEN 3
|
||||
WHEN age_days <= 365 THEN 4
|
||||
ELSE 5
|
||||
END AS sort_order,
|
||||
COUNT(*) AS product_count,
|
||||
ROUND(AVG(
|
||||
CASE WHEN avg_stock_units_30d > 0
|
||||
THEN (sales_30d::numeric / avg_stock_units_30d) * 100
|
||||
END
|
||||
)::numeric, 1) AS avg_sell_through,
|
||||
SUM(current_stock_cost) AS stock_cost,
|
||||
SUM(revenue_30d) AS revenue,
|
||||
SUM(sales_30d) AS units_sold
|
||||
FROM product_metrics
|
||||
WHERE is_visible = true
|
||||
AND current_stock > 0
|
||||
AND age_days IS NOT NULL
|
||||
GROUP BY 1, 2
|
||||
ORDER BY sort_order
|
||||
`);
|
||||
|
||||
res.json(rows.map(r => ({
|
||||
cohort: r.cohort,
|
||||
productCount: Number(r.product_count) || 0,
|
||||
avgSellThrough: Number(r.avg_sell_through) || 0,
|
||||
stockCost: Number(r.stock_cost) || 0,
|
||||
revenue: Number(r.revenue) || 0,
|
||||
unitsSold: Number(r.units_sold) || 0,
|
||||
})));
|
||||
} catch (error) {
|
||||
console.error('Error fetching aging data:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch aging data' });
|
||||
}
|
||||
});
|
||||
|
||||
// Reorder risk — lead time vs sells_out_in_days
|
||||
router.get('/stockout-risk', async (req, res) => {
|
||||
try {
|
||||
const pool = req.app.locals.pool;
|
||||
|
||||
// CTE shared by chart data and summary
|
||||
const leadTimeSql = `
|
||||
LEAST(180, CASE
|
||||
WHEN avg_lead_time_days IS NOT NULL AND avg_lead_time_days > 0 THEN avg_lead_time_days
|
||||
WHEN config_lead_time IS NOT NULL AND config_lead_time > 0 THEN config_lead_time
|
||||
ELSE 14
|
||||
END)`;
|
||||
|
||||
// Summary: accurate counts across ALL products (not just the chart sample)
|
||||
const { rows: [summary] } = await pool.query(`
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE sells_out_in_days <= ${leadTimeSql}) AS at_risk_count,
|
||||
COUNT(*) FILTER (WHERE sells_out_in_days <= ${leadTimeSql} AND abc_class = 'A') AS critical_a_count,
|
||||
COALESCE(SUM(revenue_30d) FILTER (WHERE sells_out_in_days <= ${leadTimeSql}), 0) AS at_risk_revenue
|
||||
FROM product_metrics
|
||||
WHERE is_visible = true
|
||||
AND is_replenishable = true
|
||||
AND sells_out_in_days IS NOT NULL
|
||||
AND sells_out_in_days > 0
|
||||
AND sales_30d > 0
|
||||
`);
|
||||
|
||||
// Sample products on both sides of the risk line so the diagonal is meaningful.
|
||||
// 60 at-risk (buffer <= 0), 40 closest safe (buffer > 0).
|
||||
const { rows } = await pool.query(`
|
||||
WITH base AS (
|
||||
SELECT
|
||||
title, sku, vendor,
|
||||
${leadTimeSql} AS lead_time_days,
|
||||
sells_out_in_days, current_stock, sales_velocity_daily,
|
||||
revenue_30d, abc_class
|
||||
FROM product_metrics
|
||||
WHERE is_visible = true
|
||||
AND is_replenishable = true
|
||||
AND sells_out_in_days IS NOT NULL
|
||||
AND sells_out_in_days > 0
|
||||
AND sales_30d > 0
|
||||
)
|
||||
(SELECT * FROM base WHERE sells_out_in_days <= lead_time_days
|
||||
ORDER BY (sells_out_in_days - lead_time_days) ASC LIMIT 60)
|
||||
UNION ALL
|
||||
(SELECT * FROM base WHERE sells_out_in_days > lead_time_days
|
||||
ORDER BY (sells_out_in_days - lead_time_days) ASC LIMIT 40)
|
||||
`);
|
||||
|
||||
res.json({
|
||||
summary: {
|
||||
atRiskCount: Number(summary.at_risk_count) || 0,
|
||||
criticalACount: Number(summary.critical_a_count) || 0,
|
||||
atRiskRevenue: Number(summary.at_risk_revenue) || 0,
|
||||
},
|
||||
products: rows.map(r => ({
|
||||
title: r.title,
|
||||
sku: r.sku,
|
||||
vendor: r.vendor,
|
||||
leadTimeDays: Number(r.lead_time_days) || 0,
|
||||
sellsOutInDays: Number(r.sells_out_in_days) || 0,
|
||||
currentStock: Number(r.current_stock) || 0,
|
||||
velocityDaily: Number(r.sales_velocity_daily) || 0,
|
||||
revenue30d: Number(r.revenue_30d) || 0,
|
||||
abcClass: r.abc_class || 'N/A',
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching stockout risk:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch stockout risk' });
|
||||
}
|
||||
});
|
||||
|
||||
// Discount / markdown impact
|
||||
router.get('/discounts', async (req, res) => {
|
||||
try {
|
||||
const pool = req.app.locals.pool;
|
||||
|
||||
const { rows } = await pool.query(`
|
||||
SELECT
|
||||
COALESCE(abc_class, 'N/A') AS abc_class,
|
||||
CASE
|
||||
WHEN discount_rate_30d IS NULL OR discount_rate_30d <= 0 THEN 'No Discount'
|
||||
WHEN discount_rate_30d <= 10 THEN '1-10%'
|
||||
WHEN discount_rate_30d <= 20 THEN '11-20%'
|
||||
WHEN discount_rate_30d <= 30 THEN '21-30%'
|
||||
ELSE '30%+'
|
||||
END AS discount_bucket,
|
||||
CASE
|
||||
WHEN discount_rate_30d IS NULL OR discount_rate_30d <= 0 THEN 0
|
||||
WHEN discount_rate_30d <= 10 THEN 1
|
||||
WHEN discount_rate_30d <= 20 THEN 2
|
||||
WHEN discount_rate_30d <= 30 THEN 3
|
||||
ELSE 4
|
||||
END AS sort_order,
|
||||
COUNT(*) AS product_count,
|
||||
ROUND(AVG(CASE WHEN avg_stock_units_30d > 0 THEN (sales_30d::numeric / avg_stock_units_30d) * 100 END)::numeric, 1) AS avg_sell_through,
|
||||
SUM(revenue_30d) AS revenue,
|
||||
SUM(discounts_30d) AS discount_amount,
|
||||
SUM(profit_30d) AS profit
|
||||
FROM product_metrics
|
||||
WHERE is_visible = true
|
||||
AND sales_30d > 0
|
||||
AND abc_class IS NOT NULL
|
||||
GROUP BY 1, 2, 3
|
||||
ORDER BY abc_class, sort_order
|
||||
`);
|
||||
|
||||
res.json(rows.map(r => ({
|
||||
abcClass: r.abc_class,
|
||||
discountBucket: r.discount_bucket,
|
||||
productCount: Number(r.product_count) || 0,
|
||||
avgSellThrough: Number(r.avg_sell_through) || 0,
|
||||
revenue: Number(r.revenue) || 0,
|
||||
discountAmount: Number(r.discount_amount) || 0,
|
||||
profit: Number(r.profit) || 0,
|
||||
})));
|
||||
} catch (error) {
|
||||
console.error('Error fetching discount analysis:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch discount analysis' });
|
||||
}
|
||||
});
|
||||
|
||||
// YoY growth momentum
|
||||
router.get('/growth', async (req, res) => {
|
||||
try {
|
||||
const pool = req.app.locals.pool;
|
||||
|
||||
const { rows } = await pool.query(`
|
||||
SELECT
|
||||
COALESCE(abc_class, 'N/A') AS abc_class,
|
||||
CASE
|
||||
WHEN sales_growth_yoy > 50 THEN 'Strong Growth (>50%)'
|
||||
WHEN sales_growth_yoy > 0 THEN 'Growing (0-50%)'
|
||||
WHEN sales_growth_yoy > -50 THEN 'Declining (0-50%)'
|
||||
ELSE 'Sharp Decline (>50%)'
|
||||
END AS growth_bucket,
|
||||
CASE
|
||||
WHEN sales_growth_yoy > 50 THEN 0
|
||||
WHEN sales_growth_yoy > 0 THEN 1
|
||||
WHEN sales_growth_yoy > -50 THEN 2
|
||||
ELSE 3
|
||||
END AS sort_order,
|
||||
COUNT(*) AS product_count,
|
||||
SUM(revenue_30d) AS revenue,
|
||||
SUM(current_stock_cost) AS stock_cost
|
||||
FROM product_metrics
|
||||
WHERE is_visible = true
|
||||
AND sales_growth_yoy IS NOT NULL
|
||||
GROUP BY 1, 2, 3
|
||||
ORDER BY abc_class, sort_order
|
||||
`);
|
||||
|
||||
// Summary stats
|
||||
const { rows: [summary] } = await pool.query(`
|
||||
SELECT
|
||||
COUNT(*) AS total_with_yoy,
|
||||
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(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
|
||||
`);
|
||||
|
||||
res.json({
|
||||
byClass: rows.map(r => ({
|
||||
abcClass: r.abc_class,
|
||||
growthBucket: r.growth_bucket,
|
||||
productCount: Number(r.product_count) || 0,
|
||||
revenue: Number(r.revenue) || 0,
|
||||
stockCost: Number(r.stock_cost) || 0,
|
||||
})),
|
||||
summary: {
|
||||
totalWithYoy: Number(summary.total_with_yoy) || 0,
|
||||
growingCount: Number(summary.growing_count) || 0,
|
||||
decliningCount: Number(summary.declining_count) || 0,
|
||||
avgGrowth: Number(summary.avg_growth) || 0,
|
||||
medianGrowth: Number(summary.median_growth) || 0,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching growth data:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch growth data' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
Reference in New Issue
Block a user