/** * WS0c: Procurement Intelligence API * * Endpoints: * GET /api/procurement/overview — Dashboard summary * GET /api/procurement/signals — Active reorder signals * GET /api/procurement/signals/:id — Signal for a specific transceiver * GET /api/procurement/abc — ABC classification list * GET /api/procurement/market-intel — Market intelligence events * GET /api/procurement/stock-trends/:id — Stock history for a transceiver * GET /api/procurement/lifecycle — Lifecycle events (EOL, standards) */ import { Router, Request, Response } from "express"; import { pool } from "../db/client"; export const procurementRouter = Router(); // ───────────────────────────────────────────────────────────────────────────── // GET /api/procurement/overview // ───────────────────────────────────────────────────────────────────────────── procurementRouter.get("/overview", async (_req: Request, res: Response) => { try { const [signals, abc, intel, lifecycle] = await Promise.all([ pool.query(` SELECT signal, COUNT(*) AS count FROM reorder_signals WHERE expires_at > NOW() AND computed_at = (SELECT MAX(r2.computed_at) FROM reorder_signals r2 WHERE r2.transceiver_id = reorder_signals.transceiver_id) GROUP BY signal `), pool.query(` SELECT abc_class, COUNT(*) AS count FROM abc_classification GROUP BY abc_class ORDER BY abc_class `), pool.query(` SELECT intel_type, buy_signal_implication, COUNT(*) AS count FROM market_intelligence WHERE created_at > NOW() - INTERVAL '90 days' GROUP BY intel_type, buy_signal_implication ORDER BY count DESC LIMIT 10 `), pool.query(` SELECT event_type, impact_level, COUNT(*) AS count FROM product_lifecycle_events WHERE created_at > NOW() - INTERVAL '180 days' GROUP BY event_type, impact_level ORDER BY count DESC `), ]); res.json({ signals_summary: signals.rows, abc_summary: abc.rows, market_intel_summary: intel.rows, lifecycle_summary: lifecycle.rows, }); } catch (err) { console.error("Procurement overview error:", err); res.status(500).json({ error: "Internal server error" }); } }); // ───────────────────────────────────────────────────────────────────────────── // GET /api/procurement/signals?signal=buy_now&abc_class=A&limit=50&offset=0 // ───────────────────────────────────────────────────────────────────────────── procurementRouter.get("/signals", async (req: Request, res: Response) => { try { const { signal, abc_class, form_factor, speed_gbps, limit = "50", offset = "0" } = req.query; let sql = ` SELECT rs.*, t.part_number, t.standard_name, t.form_factor, t.speed_gbps, t.reach_label, t.image_url, t.image_r2_key, ac.abc_class, ac.demand_score, ac.supply_risk, v.name AS vendor_name FROM reorder_signals rs JOIN transceivers t ON rs.transceiver_id = t.id LEFT JOIN abc_classification ac ON ac.transceiver_id = t.id LEFT JOIN vendors v ON t.vendor_id = v.id WHERE rs.expires_at > NOW() AND rs.computed_at = ( SELECT MAX(r2.computed_at) FROM reorder_signals r2 WHERE r2.transceiver_id = rs.transceiver_id ) `; const params: any[] = []; let idx = 1; if (signal) { sql += ` AND rs.signal = $${idx}`; params.push(signal); idx++; } if (abc_class) { sql += ` AND ac.abc_class = $${idx}`; params.push(abc_class); idx++; } if (form_factor) { sql += ` AND t.form_factor = $${idx}`; params.push(form_factor); idx++; } if (speed_gbps) { sql += ` AND t.speed_gbps = $${idx}`; params.push(parseFloat(speed_gbps as string)); idx++; } sql += ` ORDER BY rs.signal_strength DESC LIMIT $${idx} OFFSET $${idx + 1}`; params.push(parseInt(limit as string), parseInt(offset as string)); const result = await pool.query(sql, params); res.json({ data: result.rows, total: result.rowCount }); } catch (err) { console.error("Signals error:", err); res.status(500).json({ error: "Internal server error" }); } }); // ───────────────────────────────────────────────────────────────────────────── // GET /api/procurement/signals/:transceiver_id // ───────────────────────────────────────────────────────────────────────────── procurementRouter.get("/signals/:id", async (req: Request, res: Response) => { try { const { id } = req.params; const [signal, stockHistory, priceHistory, lifecycle] = await Promise.all([ pool.query(` SELECT rs.*, ac.abc_class, ac.demand_score, ac.supply_risk FROM reorder_signals rs LEFT JOIN abc_classification ac ON ac.transceiver_id = rs.transceiver_id WHERE rs.transceiver_id::text = $1 ORDER BY rs.computed_at DESC LIMIT 1 `, [id]), pool.query(` SELECT ss.stock_level, ss.stock_quantity, ss.incoming_quantity, ss.incoming_eta, ss.lead_time_days, ss.moq, ss.price_breaks, ss.scraped_at, ss.crawler_confidence, v.name AS vendor_name FROM stock_snapshots ss JOIN vendors v ON ss.vendor_id = v.id WHERE ss.transceiver_id::text = $1 ORDER BY ss.scraped_at DESC LIMIT 50 `, [id]), pool.query(` SELECT po.price, po.currency, po.time, v.name AS vendor_name FROM price_observations po JOIN vendors v ON po.source_vendor_id = v.id WHERE po.transceiver_id::text = $1 ORDER BY po.time DESC LIMIT 30 `, [id]), pool.query(` SELECT * FROM product_lifecycle_events WHERE transceiver_id::text = $1 ORDER BY effective_date ASC NULLS LAST, created_at DESC `, [id]), ]); res.json({ signal: signal.rows[0] || null, stock_history: stockHistory.rows, price_history: priceHistory.rows, lifecycle_events: lifecycle.rows, }); } catch (err) { console.error("Signal detail error:", err); res.status(500).json({ error: "Internal server error" }); } }); // ───────────────────────────────────────────────────────────────────────────── // GET /api/procurement/abc?class=A&form_factor=QSFP28 // ───────────────────────────────────────────────────────────────────────────── procurementRouter.get("/abc", async (req: Request, res: Response) => { try { const { class: cls, form_factor, speed_gbps, limit = "100", offset = "0" } = req.query; let sql = ` SELECT ac.*, t.part_number, t.standard_name, t.form_factor, t.speed_gbps, t.reach_label, t.image_url, v.name AS vendor_name, rs.signal, rs.signal_strength FROM abc_classification ac JOIN transceivers t ON ac.transceiver_id = t.id LEFT JOIN vendors v ON t.vendor_id = v.id LEFT JOIN LATERAL ( SELECT signal, signal_strength FROM reorder_signals WHERE transceiver_id = ac.transceiver_id AND expires_at > NOW() ORDER BY computed_at DESC LIMIT 1 ) rs ON true WHERE 1=1 `; const params: any[] = []; let idx = 1; if (cls) { sql += ` AND ac.abc_class = $${idx}`; params.push(cls); idx++; } if (form_factor) { sql += ` AND t.form_factor = $${idx}`; params.push(form_factor); idx++; } if (speed_gbps) { sql += ` AND t.speed_gbps = $${idx}`; params.push(parseFloat(speed_gbps as string)); idx++; } sql += ` ORDER BY ac.abc_class, ac.demand_score DESC LIMIT $${idx} OFFSET $${idx + 1}`; params.push(parseInt(limit as string), parseInt(offset as string)); const result = await pool.query(sql, params); res.json({ data: result.rows, total: result.rowCount }); } catch (err) { console.error("ABC error:", err); res.status(500).json({ error: "Internal server error" }); } }); // ───────────────────────────────────────────────────────────────────────────── // GET /api/procurement/market-intel?type=&days=90&signal=buy_now // ───────────────────────────────────────────────────────────────────────────── procurementRouter.get("/market-intel", async (req: Request, res: Response) => { try { const { type, days = "90", signal, technology, limit = "50", offset = "0" } = req.query; let sql = ` SELECT * FROM market_intelligence WHERE created_at > NOW() - INTERVAL '1 day' * $1 `; const params: any[] = [parseInt(days as string)]; let idx = 2; if (type) { sql += ` AND intel_type = $${idx}`; params.push(type); idx++; } if (signal) { sql += ` AND buy_signal_implication = $${idx}`; params.push(signal); idx++; } if (technology) { sql += ` AND $${idx} = ANY(technologies)`; params.push(technology); idx++; } sql += ` ORDER BY relevance_score DESC, created_at DESC LIMIT $${idx} OFFSET $${idx + 1}`; params.push(parseInt(limit as string), parseInt(offset as string)); const result = await pool.query(sql, params); res.json({ data: result.rows, total: result.rowCount }); } catch (err) { console.error("Market intel error:", err); res.status(500).json({ error: "Internal server error" }); } }); // ───────────────────────────────────────────────────────────────────────────── // GET /api/procurement/stock-trends/:transceiver_id // ───────────────────────────────────────────────────────────────────────────── procurementRouter.get("/stock-trends/:id", async (req: Request, res: Response) => { try { const result = await pool.query(` SELECT DISTINCT ON (ss.vendor_id, date_trunc('day', ss.scraped_at)) ss.stock_level, ss.stock_quantity, ss.incoming_quantity, ss.incoming_eta, ss.lead_time_days, ss.scraped_at, v.name AS vendor_name FROM stock_snapshots ss JOIN vendors v ON ss.vendor_id = v.id WHERE ss.transceiver_id::text = $1 ORDER BY ss.vendor_id, date_trunc('day', ss.scraped_at) DESC, ss.scraped_at DESC LIMIT 200 `, [req.params.id]); res.json({ data: result.rows }); } catch (err) { console.error("Stock trends error:", err); res.status(500).json({ error: "Internal server error" }); } }); // ───────────────────────────────────────────────────────────────────────────── // GET /api/procurement/lifecycle?type=eol_announced&impact=high&days=180 // ───────────────────────────────────────────────────────────────────────────── procurementRouter.get("/lifecycle", async (req: Request, res: Response) => { try { const { type, impact, technology, signal, days = "180", limit = "50" } = req.query; let sql = ` SELECT ple.*, t.part_number, t.standard_name, t.form_factor, t.speed_gbps FROM product_lifecycle_events ple LEFT JOIN transceivers t ON ple.transceiver_id = t.id WHERE ple.created_at > NOW() - INTERVAL '1 day' * $1 `; const params: any[] = [parseInt(days as string)]; let idx = 2; if (type) { sql += ` AND ple.event_type = $${idx}`; params.push(type); idx++; } if (impact) { sql += ` AND ple.impact_level = $${idx}`; params.push(impact); idx++; } if (technology) { sql += ` AND ple.technology ILIKE $${idx}`; params.push(`%${technology}%`); idx++; } if (signal) { sql += ` AND ple.buy_signal = $${idx}`; params.push(signal); idx++; } sql += ` ORDER BY ple.impact_level DESC, ple.effective_date ASC NULLS LAST, ple.created_at DESC LIMIT $${idx}`; params.push(parseInt(limit as string)); const result = await pool.query(sql, params); res.json({ data: result.rows }); } catch (err) { console.error("Lifecycle error:", err); res.status(500).json({ error: "Internal server error" }); } });