Enhance sticky columns in import, enhance forecasting page
This commit is contained in:
@@ -163,6 +163,148 @@ router.get('/forecast', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Enhanced forecast endpoint: returns flat product list with line/artist/category/metrics
|
||||
// for client-side grouping by line or category
|
||||
router.get('/forecast-v2', async (req, res) => {
|
||||
try {
|
||||
const pool = req.app.locals.pool;
|
||||
|
||||
const brand = (req.query.brand || '').toString();
|
||||
const startDateStr = req.query.startDate;
|
||||
const endDateStr = req.query.endDate;
|
||||
|
||||
if (!brand) {
|
||||
return res.status(400).json({ error: 'Missing required parameter: brand' });
|
||||
}
|
||||
|
||||
const endDate = endDateStr ? new Date(endDateStr) : new Date();
|
||||
const startDate = startDateStr ? new Date(startDateStr) : new Date(endDate.getTime() - 29 * 24 * 60 * 60 * 1000);
|
||||
|
||||
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
|
||||
),
|
||||
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
|
||||
),
|
||||
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, 'Uncategorized'::text
|
||||
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')
|
||||
)
|
||||
)
|
||||
)
|
||||
SELECT
|
||||
p.pid, p.title, p.sku,
|
||||
COALESCE(p.line, '') AS line,
|
||||
COALESCE(p.artist, '') AS artist,
|
||||
ppc.category_name,
|
||||
ppc.path AS category_path,
|
||||
COALESCE(pm.lifetime_sales, 0) AS lifetime_sales,
|
||||
COALESCE(pm.first_30_days_sales, 0) AS first_30d_sales,
|
||||
COALESCE(pm.first_60_days_sales, 0) AS first_60d_sales,
|
||||
COALESCE(pm.first_90_days_sales, 0) AS first_90d_sales,
|
||||
COALESCE(pm.current_stock, 0) AS current_stock,
|
||||
COALESCE(pm.date_first_received, pfr.first_received_date)::date AS date_first_received,
|
||||
COALESCE(p.stock_quantity, 0) AS stock_quantity,
|
||||
COALESCE(p.price, 0) AS price
|
||||
FROM recent_products rp
|
||||
JOIN products p ON p.pid = rp.pid
|
||||
JOIN product_pick_category ppc ON ppc.pid = p.pid
|
||||
LEFT JOIN product_metrics pm ON pm.pid = p.pid
|
||||
LEFT JOIN product_first_received pfr ON pfr.pid = p.pid
|
||||
ORDER BY COALESCE(p.line, ''), ppc.category_name, pm.lifetime_sales DESC NULLS LAST
|
||||
`;
|
||||
|
||||
const { rows } = await pool.query(sql, [startISO, endISO, brand]);
|
||||
|
||||
// Collect distinct artists
|
||||
const artistSet = new Set();
|
||||
const products = rows.map(r => {
|
||||
if (r.artist) artistSet.add(r.artist);
|
||||
return {
|
||||
pid: String(r.pid),
|
||||
title: r.title,
|
||||
sku: r.sku,
|
||||
line: r.line || '',
|
||||
artist: r.artist || '',
|
||||
category: r.category_name,
|
||||
categoryPath: r.category_path,
|
||||
lifetimeSales: Number(r.lifetime_sales) || 0,
|
||||
first30dSales: Number(r.first_30d_sales) || 0,
|
||||
first60dSales: Number(r.first_60d_sales) || 0,
|
||||
first90dSales: Number(r.first_90d_sales) || 0,
|
||||
currentStock: Number(r.current_stock) || 0,
|
||||
dateFirstReceived: r.date_first_received || null,
|
||||
price: Number(r.price) || 0,
|
||||
};
|
||||
});
|
||||
|
||||
res.json({
|
||||
products,
|
||||
artists: [...artistSet].sort(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching forecast-v2 data:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch forecast data' });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Inventory Intelligence Endpoints ────────────────────────────────────────
|
||||
|
||||
// Inventory KPI summary cards
|
||||
|
||||
@@ -134,11 +134,10 @@ router.post('/', async (req, res) => {
|
||||
res.status(201).json(result.rows[0]);
|
||||
} catch (error) {
|
||||
console.error('Error creating template:', error);
|
||||
// Check for unique constraint violation
|
||||
if (error instanceof Error && error.message.includes('unique constraint')) {
|
||||
return res.status(409).json({
|
||||
error: 'Template already exists for this company and product type',
|
||||
details: error.message
|
||||
// Check for unique constraint violation (PostgreSQL error code 23505)
|
||||
if (error?.code === '23505') {
|
||||
return res.status(409).json({
|
||||
error: 'A template already exists for this company and product type combination. Please edit the existing template instead.',
|
||||
});
|
||||
}
|
||||
res.status(500).json({
|
||||
@@ -232,11 +231,10 @@ router.put('/:id', async (req, res) => {
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
console.error('Error updating template:', error);
|
||||
// Check for unique constraint violation
|
||||
if (error instanceof Error && error.message.includes('unique constraint')) {
|
||||
return res.status(409).json({
|
||||
error: 'Template already exists for this company and product type',
|
||||
details: error.message
|
||||
// Check for unique constraint violation (PostgreSQL error code 23505)
|
||||
if (error?.code === '23505') {
|
||||
return res.status(409).json({
|
||||
error: 'A template already exists for this company and product type combination. Please edit the existing template instead.',
|
||||
});
|
||||
}
|
||||
res.status(500).json({
|
||||
|
||||
Reference in New Issue
Block a user