Add AI name/description validation to product editor

This commit is contained in:
2026-02-17 09:54:37 -05:00
parent bae8c575bc
commit c3e09d5fd1
208 changed files with 833 additions and 71901 deletions

View File

@@ -1,841 +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(`
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({
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.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,
});
} 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 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
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 brand (single combined query)
router.get('/efficiency', async (req, res) => {
try {
const pool = req.app.locals.pool;
const { rows } = await pool.query(`
SELECT
COALESCE(brand, 'Unbranded') AS brand_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)) * 12
ELSE 0
END AS gmroi
FROM product_metrics
WHERE is_visible = true
AND brand IS NOT NULL
AND current_stock_cost > 0
GROUP BY brand
HAVING SUM(current_stock_cost) > 100
ORDER BY SUM(current_stock_cost) DESC
LIMIT 30
`);
res.json({
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,
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, brand,
${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,
brand: r.brand,
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;
// 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,
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
AND sales_30d > 0
GROUP BY 1, 2, 3
ORDER BY abc_class, sort_order
`);
// Summary: comparable products (sold in both periods) with revenue-weighted avg
const { rows: [summary] } = await pool.query(`
SELECT
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(
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({
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: {
comparableCount: Number(summary.comparable_count) || 0,
growingCount: Number(summary.growing_count) || 0,
decliningCount: Number(summary.declining_count) || 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);
res.status(500).json({ error: 'Failed to fetch growth data' });
}
});
// 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;