diff --git a/packages/api/src/routes/procurement.ts b/packages/api/src/routes/procurement.ts index 0f03c9a..3405b0d 100644 --- a/packages/api/src/routes/procurement.ts +++ b/packages/api/src/routes/procurement.ts @@ -9,9 +9,12 @@ * 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) + * GET /api/procurement/ai-clusters — AI datacenter announcements with transceiver demand + * GET /api/procurement/internal-demand — Flexoptix internal demand velocity (fast/slow/dead) */ import { Router, Request, Response } from "express"; import { pool } from "../db/client"; +import { sendCSV } from "../utils/csv"; export const procurementRouter = Router(); @@ -22,11 +25,13 @@ 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 + WITH latest AS ( + SELECT DISTINCT ON (transceiver_id) signal + FROM reorder_signals + WHERE expires_at > NOW() + ORDER BY transceiver_id, computed_at DESC + ) + SELECT signal, COUNT(*) AS count FROM latest GROUP BY signal `), pool.query(` SELECT abc_class, COUNT(*) AS count FROM abc_classification GROUP BY abc_class ORDER BY abc_class @@ -70,31 +75,47 @@ procurementRouter.get("/signals", async (req: Request, res: Response) => { limit = "50", offset = "0" } = req.query; - let sql = ` + // Use DISTINCT ON with the existing idx_reorder_transceiver index instead of + // a correlated subquery that would run once per active row (108k+ scans). + const params: any[] = []; + let idx = 1; + + const signalFilter = signal ? ` AND rs.signal = $${idx++}` : ""; + if (signal) params.push(signal); + const abcFilter = abc_class ? ` AND ac.abc_class = $${idx++}` : ""; + if (abc_class) params.push(abc_class); + const ffFilter = form_factor ? ` AND t.form_factor = $${idx++}` : ""; + if (form_factor) params.push(form_factor); + const speedFilter = speed_gbps ? ` AND t.speed_gbps = $${idx++}` : ""; + if (speed_gbps) params.push(parseFloat(speed_gbps as string)); + + params.push(parseInt(limit as string), parseInt(offset as string)); + const limitIdx = idx; idx++; + const offsetIdx = idx; + + const sql = ` + WITH latest AS ( + SELECT DISTINCT ON (transceiver_id) + id, transceiver_id, signal, signal_strength, reasons, + stock_trend, price_trend, lead_time_weeks, hype_phase, + computed_at, expires_at, is_demo_data + FROM reorder_signals + WHERE expires_at > NOW() + ORDER BY transceiver_id, computed_at DESC + ) 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 + FROM latest 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 - ) + WHERE 1=1${signalFilter}${abcFilter}${ffFilter}${speedFilter} + ORDER BY rs.signal_strength DESC + LIMIT $${limitIdx} OFFSET $${offsetIdx} `; - 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 }); @@ -193,6 +214,9 @@ procurementRouter.get("/abc", async (req: Request, res: Response) => { params.push(parseInt(limit as string), parseInt(offset as string)); const result = await pool.query(sql, params); + if ((req.query.format as string) === "csv") { + return sendCSV(res, result.rows, `tip-abc-classification-${new Date().toISOString().slice(0,10)}.csv`); + } res.json({ data: result.rows, total: result.rowCount }); } catch (err) { console.error("ABC error:", err); @@ -291,3 +315,833 @@ procurementRouter.get("/lifecycle", async (req: Request, res: Response) => { res.status(500).json({ error: "Internal server error" }); } }); + +// ───────────────────────────────────────────────────────────────────────────── +// GET /api/procurement/ai-clusters?days=90&limit=50&min_transceivers=0 +// Returns AI datacenter announcements with transceiver demand estimates +// ───────────────────────────────────────────────────────────────────────────── +procurementRouter.get("/ai-clusters", async (req: Request, res: Response) => { + try { + const { + days = "90", + limit = "50", + min_transceivers = "0", + } = req.query; + + const daysN = Math.min(Math.max(parseInt(days as string) || 90, 1), 730); + const limitN = Math.min(parseInt(limit as string) || 50, 200); + const minTx = parseInt(min_transceivers as string) || 0; + + const result = await pool.query( + `SELECT + id, company, title, summary, + announced_date, scale_mw, scale_servers, + network_speed, estimated_transceivers, + deployment_date, location, source_url, source_name, + created_at + FROM ai_cluster_announcements + WHERE + (announced_date IS NULL OR announced_date >= NOW() - INTERVAL '1 day' * $1) + AND ($2 = 0 OR estimated_transceivers >= $2) + ORDER BY announced_date DESC NULLS LAST, created_at DESC + LIMIT $3`, + [daysN, minTx, limitN] + ); + + // Aggregate stats + const statsResult = await pool.query( + `SELECT + COUNT(*) AS total, + COUNT(*) FILTER (WHERE estimated_transceivers > 0) AS with_estimates, + SUM(estimated_transceivers) FILTER (WHERE estimated_transceivers > 0) AS total_estimated_transceivers, + SUM(scale_mw) FILTER (WHERE scale_mw IS NOT NULL) AS total_mw, + COUNT(DISTINCT company) FILTER (WHERE company != 'Unknown') AS distinct_companies + FROM ai_cluster_announcements + WHERE announced_date >= NOW() - INTERVAL '1 day' * $1`, + [daysN] + ); + + res.json({ + data: result.rows, + stats: statsResult.rows[0], + period_days: daysN, + }); + } catch (err) { + console.error("AI clusters error:", err); + res.status(500).json({ error: "Internal server error" }); + } +}); + +// ───────────────────────────────────────────────────────────────────────────── +// GET /api/procurement/internal-demand?velocity_class=fast_mover&limit=100 +// Returns Flexoptix internal demand data — real SKU velocity from internal data +// ───────────────────────────────────────────────────────────────────────────── +procurementRouter.get("/internal-demand", async (req: Request, res: Response) => { + try { + const { + velocity_class, + limit = "100", + offset = "0", + sort = "demand_12m", + } = req.query; + + const allowedSorts: Record = { + demand_12m: "fid.demand_12m DESC", + demand_3m: "fid.demand_3m DESC", + trend: "fid.demand_trend_pct DESC NULLS LAST", + sku: "fid.sku ASC", + }; + const orderBy = allowedSorts[sort as string] ?? allowedSorts["demand_12m"]; + + const params: unknown[] = []; + const conditions: string[] = ["fid.is_internal = true"]; + let idx = 1; + + if (velocity_class) { + conditions.push(`fid.velocity_class = $${idx}`); + params.push(velocity_class); + idx++; + } + + params.push(Math.min(parseInt(limit as string) || 100, 500)); + params.push(Math.max(parseInt(offset as string) || 0, 0)); + + const result = await pool.query( + `SELECT + fid.id, fid.sku, fid.description, + fid.demand_12m, fid.demand_3m, fid.demand_trend_pct, + fid.velocity_class, fid.imported_at, + t.part_number, t.standard_name, t.form_factor, t.speed_gbps, + t.reach_label, t.image_url, + v.name AS vendor_name + FROM flexoptix_internal_demand fid + LEFT JOIN transceivers t ON t.id = fid.transceiver_id + LEFT JOIN vendors v ON v.id = t.vendor_id + WHERE ${conditions.join(" AND ")} + ORDER BY ${orderBy} + LIMIT $${idx} OFFSET $${idx + 1}`, + params + ); + + // Velocity summary + const summaryResult = await pool.query( + `SELECT + velocity_class, + COUNT(*) AS cnt, + SUM(demand_12m)::numeric(12,0) AS total_demand_12m, + AVG(demand_trend_pct)::numeric(8,1) AS avg_trend_pct + FROM flexoptix_internal_demand + WHERE is_internal = true + GROUP BY velocity_class + ORDER BY total_demand_12m DESC NULLS LAST` + ); + + res.json({ + data: result.rows, + summary: summaryResult.rows, + total: result.rowCount, + }); + } catch (err) { + console.error("Internal demand error:", err); + res.status(500).json({ error: "Internal server error" }); + } +}); + +// ───────────────────────────────────────────────────────────────────────────── +// GET /api/procurement/hyperscaler-capex +// Hyperscaler quarterly CapEx from SEC filings — demand context for transceivers +// ───────────────────────────────────────────────────────────────────────────── +procurementRouter.get("/hyperscaler-capex", async (_req: Request, res: Response) => { + try { + const result = await pool.query(` + SELECT + company, period_label, period_end, + capex_usd_millions, dc_capex_est_millions, + yoy_growth_pct, filing_type, source_url + FROM hyperscaler_capex + ORDER BY period_end DESC, capex_usd_millions DESC + `); + + const summaryResult = await pool.query(` + SELECT + company, + MAX(period_end) AS latest_period_end, + MAX(period_label) AS latest_period, + (ARRAY_AGG(capex_usd_millions ORDER BY period_end DESC))[1] AS latest_capex, + (ARRAY_AGG(dc_capex_est_millions ORDER BY period_end DESC))[1] AS latest_dc_capex, + (ARRAY_AGG(yoy_growth_pct ORDER BY period_end DESC))[1] AS latest_yoy_growth, + AVG(yoy_growth_pct) FILTER (WHERE period_end >= NOW() - INTERVAL '365 days') AS avg_yoy_12m + FROM hyperscaler_capex + GROUP BY company + ORDER BY latest_capex DESC NULLS LAST + `); + + res.json({ + data: result.rows, + summary: summaryResult.rows, + }); + } catch (err) { + console.error("Hyperscaler capex error:", err); + res.status(500).json({ error: "Internal server error" }); + } +}); + +// ───────────────────────────────────────────────────────────────────────────── +// GET /api/procurement/marketplace-velocity +// Secondary market (eBay) sell-through as demand signal +// ───────────────────────────────────────────────────────────────────────────── +procurementRouter.get("/marketplace-velocity", async (_req: Request, res: Response) => { + try { + const result = await pool.query(` + SELECT DISTINCT ON (form_factor, speed_label) + marketplace, keyword, form_factor, speed_label, + sold_count_30d, active_listings, avg_sold_price, + min_price, max_price, currency, scraped_at + FROM marketplace_velocity + ORDER BY form_factor, speed_label, scraped_at DESC + `); + + const hotResult = await pool.query(` + SELECT DISTINCT ON (form_factor, speed_label) + marketplace, form_factor, speed_label, + sold_count_30d, active_listings, avg_sold_price + FROM marketplace_velocity + WHERE sold_count_30d > 0 + ORDER BY form_factor, speed_label, scraped_at DESC + `); + + res.json({ + data: result.rows, + hot: hotResult.rows.sort( + (a: { sold_count_30d: string }, b: { sold_count_30d: string }) => + parseInt(b.sold_count_30d) - parseInt(a.sold_count_30d) + ), + }); + } catch (err) { + console.error("Marketplace velocity error:", err); + res.status(500).json({ error: "Internal server error" }); + } +}); + +// ─── E: GET /api/procurement/reorder-top ───────────────────────────────────── +// Top buy_now reorder signals with full reasons — 211k precomputed signals +procurementRouter.get("/reorder-top", async (req: Request, res: Response) => { + const limit = Math.min(parseInt(req.query.limit as string) || 50, 200); + const formFactor = (req.query.form_factor as string) || ""; + const minStrength = parseFloat(req.query.min_strength as string) || 0; + + try { + const result = await pool.query(` + SELECT DISTINCT ON (t.id) + t.id, t.part_number, t.speed_gbps, t.form_factor, t.reach_label, + v.name AS vendor_name, + rs.signal, rs.signal_strength, + rs.price_trend, rs.stock_trend, rs.hype_phase, + rs.reasons, + rs.computed_at + FROM reorder_signals rs + JOIN transceivers t ON t.id = rs.transceiver_id + JOIN vendors v ON v.id = t.vendor_id + WHERE rs.signal = 'buy_now' + AND rs.is_demo_data = false + AND rs.signal_strength >= $1 + AND ($2 = '' OR t.form_factor ILIKE $2) + ORDER BY t.id, rs.signal_strength DESC, rs.computed_at DESC + `, [minStrength, formFactor]); + + // After DISTINCT ON, re-sort by signal_strength + const rows = result.rows.sort( + (a: { signal_strength: string }, b: { signal_strength: string }) => + parseFloat(b.signal_strength) - parseFloat(a.signal_strength) + ); + + const summary = await pool.query(` + SELECT + COUNT(*) FILTER (WHERE signal = 'buy_now' AND is_demo_data = false)::int AS buy_now, + COUNT(*) FILTER (WHERE signal = 'wait' AND is_demo_data = false)::int AS wait, + COUNT(*) FILTER (WHERE signal = 'hold' AND is_demo_data = false)::int AS hold, + COUNT(*) FILTER (WHERE signal = 'monitor' AND is_demo_data = false)::int AS monitor, + ROUND(AVG(signal_strength) FILTER (WHERE signal = 'buy_now' AND is_demo_data = false)::numeric,3) AS avg_buy_strength + FROM reorder_signals + `); + + res.json({ success: true, data: rows.slice(0, limit), summary: summary.rows[0] }); + } catch (err) { + res.status(500).json({ error: String(err) }); + } +}); + +// ─── B: GET /api/procurement/switch-compat ─────────────────────────────────── +// Switch ↔ transceiver compatibility matrix +procurementRouter.get("/switch-compat", async (req: Request, res: Response) => { + const search = (req.query.search as string) || ""; + const limitNum = Math.min(parseInt(req.query.limit as string) || 30, 100); + + try { + if (search.length >= 2) { + // Search for switches matching query, return their compatible transceivers + const switches = await pool.query(` + SELECT DISTINCT ON (sw.id) + sw.id, v.name AS sw_vendor, sw.model AS sw_model, sw.series AS sw_series, + COUNT(c.transceiver_id) OVER (PARTITION BY sw.id)::int AS compat_count + FROM switches sw + JOIN compatibility c ON c.switch_id = sw.id + LEFT JOIN vendors v ON v.id = sw.vendor_id + WHERE sw.model ILIKE $1 OR COALESCE(v.name,'') ILIKE $1 OR sw.series ILIKE $1 + ORDER BY sw.id, compat_count DESC + LIMIT $2 + `, [`%${search}%`, limitNum]); + + // For each matched switch, get top compatible transceivers with prices + const switchIds = switches.rows.map((s: { id: string }) => s.id); + if (switchIds.length === 0) { + return res.json({ success: true, switches: [], transceivers: [] }); + } + + const transceivers = await pool.query(` + SELECT + c.switch_id, + t.id AS tx_id, t.part_number, t.speed_gbps, t.form_factor, t.reach_label, + v.name AS vendor_name, + c.verification_method, c.status, + (SELECT ROUND(MIN(po.price)::numeric,2) FROM price_observations po + WHERE po.transceiver_id = t.id AND po.price > 0) AS min_price, + (SELECT po.currency FROM price_observations po + WHERE po.transceiver_id = t.id AND po.price > 0 + ORDER BY po.time DESC LIMIT 1) AS currency + FROM compatibility c + JOIN transceivers t ON t.id = c.transceiver_id + JOIN vendors v ON v.id = t.vendor_id + WHERE c.switch_id = ANY($1) + AND c.status = 'compatible' + ORDER BY t.speed_gbps DESC, t.form_factor + LIMIT 200 + `, [switchIds]); + + return res.json({ + success: true, + switches: switches.rows, + transceivers: transceivers.rows, + }); + } + + // No search — return top switches by compat count + const top = await pool.query(` + SELECT v.name AS vendor, sw.model, sw.series, + COUNT(c.transceiver_id)::int AS compat_count + FROM switches sw + JOIN compatibility c ON c.switch_id = sw.id + LEFT JOIN vendors v ON v.id = sw.vendor_id + WHERE c.status = 'compatible' + GROUP BY sw.id, v.name, sw.model, sw.series + ORDER BY compat_count DESC + LIMIT $1 + `, [limitNum]); + + const stats = await pool.query(` + SELECT + COUNT(DISTINCT sw.id)::int AS total_switches, + COUNT(DISTINCT c.transceiver_id)::int AS total_transceivers, + COUNT(*)::int AS total_compat_rows + FROM switches sw JOIN compatibility c ON c.switch_id = sw.id + WHERE c.status = 'compatible' + `); + + return res.json({ success: true, topSwitches: top.rows, stats: stats.rows[0] }); + } catch (err) { + res.status(500).json({ error: String(err) }); + } +}); + +// ─── A: GET /api/procurement/arbitrage ─────────────────────────────────────── +// OEM vs Flexoptix price gaps via transceiver_equivalences +procurementRouter.get("/arbitrage", async (_req: Request, res: Response) => { + // FX rates for normalization — approximate + const FX: Record = { USD: 1.0, EUR: 1.08, GBP: 1.27 }; + + try { + const result = await pool.query(` + SELECT + te.confidence, + fx.part_number AS fx_part, + vfx.name AS fx_vendor, + fx.speed_gbps, fx.form_factor, fx.reach_label, + comp.part_number AS comp_part, + vcomp.name AS comp_vendor, + (SELECT price FROM price_observations WHERE transceiver_id = te.flexoptix_id AND price > 2 ORDER BY time DESC LIMIT 1) AS fx_price, + (SELECT currency FROM price_observations WHERE transceiver_id = te.flexoptix_id AND price > 2 ORDER BY time DESC LIMIT 1) AS fx_curr, + (SELECT price FROM price_observations WHERE transceiver_id = te.competitor_id AND price > 2 ORDER BY time DESC LIMIT 1) AS comp_price, + (SELECT currency FROM price_observations WHERE transceiver_id = te.competitor_id AND price > 2 ORDER BY time DESC LIMIT 1) AS comp_curr + FROM transceiver_equivalences te + JOIN transceivers fx ON fx.id = te.flexoptix_id + JOIN transceivers comp ON comp.id = te.competitor_id + JOIN vendors vfx ON vfx.id = fx.vendor_id + JOIN vendors vcomp ON vcomp.id = comp.vendor_id + WHERE te.status IN ('approved','auto_approved') + AND EXISTS(SELECT 1 FROM price_observations WHERE transceiver_id = te.flexoptix_id AND price > 2) + AND EXISTS(SELECT 1 FROM price_observations WHERE transceiver_id = te.competitor_id AND price > 2) + ORDER BY te.confidence DESC + LIMIT 2000 + `); + + const pairs = result.rows + .map((r: { + fx_price: string; fx_curr: string; + comp_price: string; comp_curr: string; + confidence: string; + fx_part: string; fx_vendor: string; + comp_part: string; comp_vendor: string; + speed_gbps: string; form_factor: string; reach_label: string; + }) => { + const fxUSD = parseFloat(r.fx_price) * (FX[r.fx_curr] || 1.0); + const compUSD = parseFloat(r.comp_price) * (FX[r.comp_curr] || 1.0); + if (!fxUSD || !compUSD) return null; + const savings = compUSD - fxUSD; + const savingsPct = Math.round((savings / compUSD) * 100); + return { ...r, fxUSD: Math.round(fxUSD), compUSD: Math.round(compUSD), savings: Math.round(savings), savingsPct }; + }) + .filter((r): r is NonNullable => r !== null && r.savings > 0) + .sort((a, b) => b.savingsPct - a.savingsPct) + .slice(0, 100); + + // Stats + const totalPairs = result.rows.length; + const fxCheaper = pairs.length; + const avgSavings = pairs.length ? Math.round(pairs.reduce((s, r) => s + r.savingsPct, 0) / pairs.length) : 0; + + res.json({ success: true, pairs, stats: { totalPairs, fxCheaper, avgSavingsPct: avgSavings } }); + } catch (err) { + res.status(500).json({ error: String(err) }); + } +}); + +// ─── D: GET /api/procurement/dead-stock-revival ────────────────────────────── +// Dead-stock SKUs whose equivalents are in rising hype phases +procurementRouter.get("/dead-stock-revival", async (_req: Request, res: Response) => { + try { + const [deadStock, hypeMap] = await Promise.all([ + pool.query(` + SELECT + fid.transceiver_id, + fid.sku AS part_number, + fid.velocity_class, + fid.demand_12m, + fid.demand_trend_pct, + t.speed_gbps, t.form_factor, t.reach_label, + v.name AS vendor_name + FROM flexoptix_internal_demand fid + JOIN transceivers t ON t.id = fid.transceiver_id + JOIN vendors v ON v.id = t.vendor_id + WHERE fid.velocity_class = 'dead_stock' + AND fid.is_internal = true + LIMIT 7500 + `), + pool.query(` + SELECT DISTINCT ON (technology) + technology, hype_phase, hype_score, computed_at + FROM hype_cycle_analysis + ORDER BY technology, computed_at DESC + `), + ]); + + // Build speed → hype phase map + type HypeRow = { technology: string; hype_phase: string; hype_score: string }; + const ASCENDING = new Set(["innovation_trigger","peak_inflated_expectations","slope_enlightenment","plateau_productivity"]); + const speedToHype = new Map(); + for (const h of hypeMap.rows as HypeRow[]) { + const speedMatch = h.technology.match(/^(\d+(?:\.\d+)?)G/); + if (speedMatch) speedToHype.set(parseFloat(speedMatch[1]), h); + } + + type DeadRow = { + transceiver_id: string; part_number: string; + speed_gbps: string; form_factor: string; reach_label: string; + vendor_name: string; demand_12m: string; demand_trend_pct: string; + velocity_class: string; + }; + + const revivals = (deadStock.rows as DeadRow[]) + .map((r) => { + const speed = parseFloat(r.speed_gbps); + const hype = speedToHype.get(speed); + if (!hype) return null; + const ascending = ASCENDING.has(hype.hype_phase); + const score = parseFloat(hype.hype_score); + return { ...r, hype_phase: hype.hype_phase, hype_score: score, ascending }; + }) + .filter((r): r is NonNullable => r !== null && r.ascending && r.hype_score > 30) + .sort((a, b) => b.hype_score - a.hype_score) + .slice(0, 100); + + const totalDead = deadStock.rows.length; + res.json({ success: true, revivals, totalDeadStock: totalDead, revivalCount: revivals.length }); + } catch (err) { + res.status(500).json({ error: String(err) }); + } +}); + +// ─── C: GET /api/procurement/supply-squeeze ────────────────────────────────── +// Multi-signal supply constraint detector +procurementRouter.get("/supply-squeeze", async (_req: Request, res: Response) => { + try { + const [priceSignals, aiDemand, hypeData, stockData] = await Promise.all([ + // Price momentum: 30d vs 60d avg by speed/form_factor + pool.query(` + SELECT + t.speed_gbps, t.form_factor, + ROUND(AVG(po.price) FILTER (WHERE po.time >= NOW() - INTERVAL '30 days')::numeric,2) AS avg_30d, + ROUND(AVG(po.price) FILTER (WHERE po.time >= NOW() - INTERVAL '60 days' AND po.time < NOW() - INTERVAL '30 days')::numeric,2) AS avg_prior_30d, + COUNT(*) FILTER (WHERE po.time >= NOW() - INTERVAL '30 days') AS obs_30d + FROM price_observations po + JOIN transceivers t ON t.id = po.transceiver_id + WHERE po.price > 5 AND po.currency = 'USD' + GROUP BY t.speed_gbps, t.form_factor + HAVING COUNT(*) FILTER (WHERE po.time >= NOW() - INTERVAL '30 days') >= 3 + `), + // AI cluster demand by speed tier + pool.query(` + SELECT + CASE + WHEN COALESCE(network_speed, title, summary, '') ILIKE '%800G%' THEN 800 + WHEN COALESCE(network_speed, title, summary, '') ILIKE '%400G%' THEN 400 + WHEN COALESCE(network_speed, title, summary, '') ILIKE '%100G%' THEN 100 + ELSE 0 + END AS speed_tier, + COALESCE(SUM(estimated_transceivers),0)::int AS total_tx, + COUNT(*)::int AS cluster_count + FROM ai_cluster_announcements + WHERE announced_date >= NOW() - INTERVAL '90 days' + GROUP BY 1 + HAVING COALESCE(SUM(estimated_transceivers),0) > 0 + `), + // Hype phase per technology + pool.query(` + SELECT DISTINCT ON (technology) + technology, hype_phase, hype_score + FROM hype_cycle_analysis ORDER BY technology, computed_at DESC + `), + // Stock level distribution (in_stock vs out_of_stock) + pool.query(` + SELECT + t.speed_gbps, t.form_factor, + COUNT(*) FILTER (WHERE so.stock_level = 'out_of_stock')::int AS out_of_stock, + COUNT(*) FILTER (WHERE so.stock_level = 'in_stock')::int AS in_stock, + COUNT(*)::int AS total_obs + FROM stock_observations so + JOIN transceivers t ON t.id = so.transceiver_id + WHERE so.observed_at >= NOW() - INTERVAL '14 days' + GROUP BY t.speed_gbps, t.form_factor + HAVING COUNT(*) >= 3 + `).catch(() => ({ rows: [] })), + ]); + + type PriceRow = { speed_gbps: string; form_factor: string; avg_30d: string; avg_prior_30d: string; obs_30d: string }; + type HypeRow = { technology: string; hype_phase: string; hype_score: string }; + type AiRow = { speed_tier: string; total_tx: string; cluster_count: string }; + type StockRow = { speed_gbps: string; form_factor: string; out_of_stock: string; in_stock: string; total_obs: string }; + + const speedToHype = new Map(); + for (const h of hypeData.rows as HypeRow[]) { + const m = h.technology.match(/^(\d+(?:\.\d+)?)G/); + if (m) speedToHype.set(parseFloat(m[1]), h); + } + + const aiBySpeed = new Map(); + for (const a of aiDemand.rows as AiRow[]) { + aiBySpeed.set(parseFloat(a.speed_tier), a); + } + + const stockByKey = new Map(); + for (const s of stockData.rows as StockRow[]) { + stockByKey.set(`${s.speed_gbps}:${s.form_factor}`, s); + } + + const RISKY_PHASES = new Set(["peak_inflated_expectations","slope_enlightenment","plateau_productivity"]); + + const signals = (priceSignals.rows as PriceRow[]) + .map((r) => { + const speed = parseFloat(r.speed_gbps); + const priceUp = r.avg_30d && r.avg_prior_30d + ? ((parseFloat(r.avg_30d) - parseFloat(r.avg_prior_30d)) / parseFloat(r.avg_prior_30d)) * 100 + : 0; + const hype = speedToHype.get(speed); + const ai = aiBySpeed.get(speed); + const stock = stockByKey.get(`${r.speed_gbps}:${r.form_factor}`); + + let activeSignals = 0; + const reasons: string[] = []; + + if (priceUp > 5) { activeSignals++; reasons.push(`Price +${Math.round(priceUp)}% (30d)`); } + if (hype && RISKY_PHASES.has(hype.hype_phase)) { activeSignals++; reasons.push(`Hype: ${hype.hype_phase.replace(/_/g,' ')}`); } + if (ai && parseInt(ai.total_tx) > 50000) { activeSignals++; reasons.push(`AI demand: ${parseInt(ai.total_tx).toLocaleString()} tx in 90d`); } + if (stock && parseInt(stock.out_of_stock) > parseInt(stock.in_stock)) { activeSignals++; reasons.push(`Stock pressure: ${stock.out_of_stock}/${stock.total_obs} vendors OOS`); } + + const severity = activeSignals >= 3 ? "critical" : activeSignals === 2 ? "warning" : activeSignals === 1 ? "watch" : "ok"; + return { + speed_gbps: r.speed_gbps, form_factor: r.form_factor, + avg_30d: r.avg_30d, avg_prior_30d: r.avg_prior_30d, + price_momentum_pct: Math.round(priceUp), + hype_phase: hype?.hype_phase || null, + hype_score: hype ? parseFloat(hype.hype_score) : null, + ai_demand_tx: ai ? parseInt(ai.total_tx) : 0, + activeSignals, severity, reasons, + }; + }) + .filter((r) => r.activeSignals >= 1) + .sort((a, b) => b.activeSignals - a.activeSignals || b.price_momentum_pct - a.price_momentum_pct); + + res.json({ success: true, signals, criticalCount: signals.filter(s => s.severity === "critical").length }); + } catch (err) { + res.status(500).json({ error: String(err) }); + } +}); + +// ───────────────────────────────────────────────────────────────────────────── +// GET /api/procurement/lead-times — Rolling lead-time trends per vendor/speed +// Query params: form_factor, speed_gbps, days (default 90), limit (default 20) +// ───────────────────────────────────────────────────────────────────────────── +procurementRouter.get("/lead-times", async (req: Request, res: Response) => { + const days = Math.min(parseInt(req.query.days as string) || 90, 365); + const limit = Math.min(parseInt(req.query.limit as string) || 20, 50); + const ff = req.query.form_factor as string | undefined; + const spd = req.query.speed_gbps as string | undefined; + + try { + const [weekly, summary, concentration] = await Promise.all([ + // Weekly avg lead time per vendor × speed tier + pool.query(` + SELECT + v.name AS vendor_name, + v.id::text AS vendor_id, + t.speed_gbps::text, + t.form_factor, + DATE_TRUNC('week', ss.scraped_at)::date AS week, + ROUND(AVG(ss.lead_time_days)::numeric, 1) AS avg_lead_days, + ROUND(MIN(ss.lead_time_days)::numeric, 1) AS min_lead_days, + ROUND(MAX(ss.lead_time_days)::numeric, 1) AS max_lead_days, + COUNT(*)::int AS observations + FROM stock_snapshots ss + JOIN transceivers t ON t.id = ss.transceiver_id + JOIN vendors v ON v.id = ss.source_vendor_id + WHERE ss.scraped_at >= NOW() - INTERVAL '${days} days' + AND ss.lead_time_days IS NOT NULL + AND ss.lead_time_days > 0 + ${ff ? `AND t.form_factor = '${ff.replace(/'/g,"''")}'` : ""} + ${spd ? `AND t.speed_gbps = ${parseFloat(spd)}` : ""} + GROUP BY v.name, v.id, t.speed_gbps, t.form_factor, DATE_TRUNC('week', ss.scraped_at) + ORDER BY week DESC, avg_lead_days DESC + LIMIT 500 + `), + + // Overall summary: current vs prior-period avg per vendor + pool.query(` + WITH cur AS ( + SELECT + v.name AS vendor_name, v.id::text AS vendor_id, + ROUND(AVG(ss.lead_time_days)::numeric, 1) AS avg_days, + COUNT(*)::int AS obs + FROM stock_snapshots ss + JOIN vendors v ON v.id = ss.source_vendor_id + JOIN transceivers t ON t.id = ss.transceiver_id + WHERE ss.scraped_at >= NOW() - INTERVAL '30 days' + AND ss.lead_time_days > 0 + ${ff ? `AND t.form_factor = '${ff.replace(/'/g,"''")}'` : ""} + ${spd ? `AND t.speed_gbps = ${parseFloat(spd)}` : ""} + GROUP BY v.name, v.id + ), + prior AS ( + SELECT v.id::text AS vendor_id, + ROUND(AVG(ss.lead_time_days)::numeric, 1) AS avg_days + FROM stock_snapshots ss + JOIN vendors v ON v.id = ss.source_vendor_id + JOIN transceivers t ON t.id = ss.transceiver_id + WHERE ss.scraped_at >= NOW() - INTERVAL '60 days' + AND ss.scraped_at < NOW() - INTERVAL '30 days' + AND ss.lead_time_days > 0 + ${ff ? `AND t.form_factor = '${ff.replace(/'/g,"''")}'` : ""} + ${spd ? `AND t.speed_gbps = ${parseFloat(spd)}` : ""} + GROUP BY v.id + ) + SELECT + c.vendor_name, c.vendor_id, + c.avg_days AS current_30d_avg, + p.avg_days AS prior_30d_avg, + ROUND((c.avg_days - COALESCE(p.avg_days, c.avg_days))::numeric, 1) AS delta_days, + c.obs + FROM cur c + LEFT JOIN prior p ON p.vendor_id = c.vendor_id + ORDER BY c.avg_days DESC + LIMIT ${limit} + `), + + // Speed-tier breakdown — which form factors have longest lead times right now + pool.query(` + SELECT + t.speed_gbps::text, t.form_factor, + ROUND(AVG(ss.lead_time_days)::numeric, 1) AS avg_lead_days, + COUNT(DISTINCT ss.source_vendor_id)::int AS vendors_reporting, + COUNT(*)::int AS total_obs + FROM stock_snapshots ss + JOIN transceivers t ON t.id = ss.transceiver_id + WHERE ss.scraped_at >= NOW() - INTERVAL '30 days' + AND ss.lead_time_days > 0 + GROUP BY t.speed_gbps, t.form_factor + HAVING COUNT(*) >= 3 + ORDER BY avg_lead_days DESC + LIMIT 20 + `), + ]); + + res.json({ + success: true, + filters: { days, form_factor: ff || null, speed_gbps: spd || null }, + weekly_trend: weekly.rows, + vendor_summary: summary.rows, + speed_tier_breakdown: concentration.rows, + }); + } catch (err) { + res.status(500).json({ error: String(err) }); + } +}); + +// ───────────────────────────────────────────────────────────────────────────── +// GET /api/procurement/supply-concentration — Single-vendor dependency risk +// Flags SKUs where >70% of price observations come from one vendor (30d) +// ───────────────────────────────────────────────────────────────────────────── +procurementRouter.get("/supply-concentration", async (_req: Request, res: Response) => { + try { + const result = await pool.query(` + WITH obs_30d AS ( + SELECT + po.transceiver_id, + po.source_vendor_id, + COUNT(*) AS vendor_obs + FROM price_observations po + WHERE po.time >= NOW() - INTERVAL '30 days' + AND po.price > 0 + AND COALESCE(po.is_anomalous, false) = false + GROUP BY po.transceiver_id, po.source_vendor_id + ), + totals AS ( + SELECT transceiver_id, SUM(vendor_obs) AS total_obs + FROM obs_30d GROUP BY transceiver_id + ), + ranked AS ( + SELECT + o.transceiver_id, + o.source_vendor_id, + o.vendor_obs, + t.total_obs, + ROUND((o.vendor_obs::numeric / NULLIF(t.total_obs,0)) * 100, 1) AS share_pct, + ROW_NUMBER() OVER (PARTITION BY o.transceiver_id ORDER BY o.vendor_obs DESC) AS rnk + FROM obs_30d o JOIN totals t ON t.transceiver_id = o.transceiver_id + ) + SELECT + tx.id::text, tx.part_number, tx.form_factor, + tx.speed_gbps::text, + tx.standard_name, + v.name AS dominant_vendor, + r.share_pct, + r.total_obs::int, + r.vendor_obs::int AS dominant_obs, + CASE + WHEN r.share_pct >= 90 THEN 'critical' + WHEN r.share_pct >= 75 THEN 'high' + ELSE 'medium' + END AS risk_level + FROM ranked r + JOIN transceivers tx ON tx.id = r.transceiver_id + JOIN vendors v ON v.id = r.source_vendor_id + WHERE r.rnk = 1 + AND r.share_pct >= 70 + AND r.total_obs >= 5 + ORDER BY r.share_pct DESC + LIMIT 50 + `); + + const rows = result.rows; + res.json({ + success: true, + concentrated: rows, + stats: { + total_at_risk: rows.length, + critical: rows.filter(r => r.risk_level === "critical").length, + high: rows.filter(r => r.risk_level === "high").length, + medium: rows.filter(r => r.risk_level === "medium").length, + }, + }); + } catch (err) { + res.status(500).json({ error: String(err) }); + } +}); + +// GET /api/procurement/price-movers — SKUs with biggest price delta vs prior period +procurementRouter.get("/price-movers", async (req: Request, res: Response) => { + const days = Math.min(parseInt(req.query.days as string) || 7, 90); + const limit = Math.min(parseInt(req.query.limit as string) || 20, 50); + + try { + const result = await pool.query(` + WITH cur AS ( + SELECT transceiver_id, source_vendor_id, currency, + AVG(price) AS avg_price, + COUNT(*) AS obs + FROM price_observations + WHERE time >= NOW() - INTERVAL '${days} days' + AND price > 0 AND COALESCE(is_anomalous, false) = false + GROUP BY transceiver_id, source_vendor_id, currency + ), + prior AS ( + SELECT transceiver_id, source_vendor_id, + AVG(price) AS avg_price + FROM price_observations + WHERE time >= NOW() - INTERVAL '${days * 2} days' + AND time < NOW() - INTERVAL '${days} days' + AND price > 0 AND COALESCE(is_anomalous, false) = false + GROUP BY transceiver_id, source_vendor_id + ) + SELECT + t.id, t.part_number, t.form_factor, + t.speed_gbps::text AS speed_gbps, + t.standard_name, + sv.name AS vendor_name, + ROUND(c.avg_price::numeric, 2) AS current_avg, + ROUND(p.avg_price::numeric, 2) AS prior_avg, + ROUND(((c.avg_price - p.avg_price) / NULLIF(p.avg_price, 0) * 100)::numeric, 1) AS delta_pct, + c.currency, + c.obs::int AS observations + FROM cur c + JOIN prior p ON p.transceiver_id = c.transceiver_id + AND p.source_vendor_id = c.source_vendor_id + JOIN transceivers t ON t.id = c.transceiver_id + JOIN vendors sv ON sv.id = c.source_vendor_id + WHERE ABS((c.avg_price - p.avg_price) / NULLIF(p.avg_price, 0) * 100) >= 2 + AND c.obs::int >= 2 + ORDER BY ABS((c.avg_price - p.avg_price) / NULLIF(p.avg_price, 0) * 100) DESC + LIMIT ${limit * 2} + `); + + const rows = result.rows; + const gainers = rows.filter((r) => parseFloat(r.delta_pct) > 0).slice(0, limit); + const losers = rows.filter((r) => parseFloat(r.delta_pct) < 0).slice(0, limit); + + const avgOf = (arr: typeof gainers, key: string) => + arr.length ? Math.round(arr.reduce((s, r) => s + parseFloat(r[key]), 0) / arr.length * 10) / 10 : 0; + + res.json({ + success: true, + days, + gainers, + losers, + stats: { + totalMovers: rows.length, + gainersCount: gainers.length, + losersCount: losers.length, + avgGainPct: avgOf(gainers, "delta_pct"), + avgLossPct: avgOf(losers, "delta_pct"), + }, + }); + } catch (err) { + res.status(500).json({ error: String(err) }); + } +}); diff --git a/packages/api/src/routes/win-loss.ts b/packages/api/src/routes/win-loss.ts new file mode 100644 index 0000000..77dfecd --- /dev/null +++ b/packages/api/src/routes/win-loss.ts @@ -0,0 +1,189 @@ +/** + * Win/Loss Intelligence — /api/win-loss + * + * Record and analyze deal outcomes: who won, who lost, at what price, in which segment. + * + * Routes: + * POST /api/win-loss — Record a win/loss event + * GET /api/win-loss — List events (filterable) + * GET /api/win-loss/summary — Aggregate win rate, avg price delta, segments + * GET /api/win-loss/competitors — Ranking by competitor vendor (loss analysis) + */ +import { Router, Request, Response } from "express"; +import { pool } from "../db/client"; +import { sendCSV } from "../utils/csv"; + +export const winLossRouter = Router(); + +// ── POST /api/win-loss — Record a deal outcome ────────────────────────────── +winLossRouter.post("/", async (req: Request, res: Response) => { + const { + outcome, transceiver_id, competitor_vendor, + our_price, competitor_price, currency = "USD", + quantity, customer_segment, deal_source, + form_factor, speed_gbps, notes, deal_date, + } = req.body as Record; + + if (!outcome || !["won","lost","unknown"].includes(outcome)) { + return res.status(400).json({ success: false, error: "outcome must be: won | lost | unknown" }); + } + + try { + const result = await pool.query( + `INSERT INTO win_loss_events + (outcome, transceiver_id, competitor_vendor, our_price, competitor_price, + currency, quantity, customer_segment, deal_source, form_factor, speed_gbps, notes, deal_date) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12, + COALESCE($13::date, CURRENT_DATE)) + RETURNING *`, + [ + outcome, + transceiver_id || null, + competitor_vendor || null, + our_price ? parseFloat(our_price) : null, + competitor_price ? parseFloat(competitor_price) : null, + currency, + quantity ? parseInt(quantity) : null, + customer_segment || null, + deal_source || null, + form_factor || null, + speed_gbps ? parseFloat(speed_gbps) : null, + notes || null, + deal_date || null, + ] + ); + return res.status(201).json({ success: true, event: result.rows[0] }); + } catch (err) { + return res.status(500).json({ success: false, error: String(err) }); + } +}); + +// ── GET /api/win-loss — List events ───────────────────────────────────────── +winLossRouter.get("/", async (req: Request, res: Response) => { + const outcome = req.query.outcome as string | undefined; + const segment = req.query.customer_segment as string | undefined; + const days = Math.min(parseInt(req.query.days as string) || 90, 730); + const limit = Math.min(parseInt(req.query.limit as string) || 50, 200); + const fmt = req.query.format as string | undefined; + + try { + const result = await pool.query(` + SELECT wl.*, + t.standard_name, t.form_factor AS tx_form_factor, t.speed_gbps AS tx_speed + FROM win_loss_events wl + LEFT JOIN transceivers t ON t.id = wl.transceiver_id + WHERE wl.deal_date >= CURRENT_DATE - INTERVAL '${days} days' + ${outcome ? `AND wl.outcome = '${outcome.replace(/'/g,"''")}'` : ""} + ${segment ? `AND wl.customer_segment = '${segment.replace(/'/g,"''")}'` : ""} + ORDER BY wl.deal_date DESC + LIMIT ${limit} + `); + + if (fmt === "csv") { + return sendCSV(res, result.rows, `tip-win-loss-${new Date().toISOString().slice(0,10)}.csv`); + } + return res.json({ success: true, events: result.rows, count: result.rows.length }); + } catch (err) { + return res.status(500).json({ success: false, error: String(err) }); + } +}); + +// ── GET /api/win-loss/summary — Aggregate analytics ───────────────────────── +winLossRouter.get("/summary", async (req: Request, res: Response) => { + const days = Math.min(parseInt(req.query.days as string) || 90, 730); + + try { + const [overall, bySegment, byFormFactor, priceDeltas] = await Promise.all([ + pool.query(` + SELECT + COUNT(*) AS total_events, + COUNT(*) FILTER (WHERE outcome = 'won') AS won, + COUNT(*) FILTER (WHERE outcome = 'lost') AS lost, + ROUND( + COUNT(*) FILTER (WHERE outcome = 'won')::numeric + / NULLIF(COUNT(*) FILTER (WHERE outcome IN ('won','lost')), 0) * 100, 1 + ) AS win_rate_pct, + ROUND(AVG(our_price) FILTER (WHERE outcome = 'won')::numeric, 2) AS avg_win_price, + ROUND(AVG(our_price) FILTER (WHERE outcome = 'lost')::numeric, 2) AS avg_loss_price + FROM win_loss_events + WHERE deal_date >= CURRENT_DATE - INTERVAL '${days} days' + `), + pool.query(` + SELECT customer_segment, + COUNT(*) AS total, + COUNT(*) FILTER (WHERE outcome = 'won') AS won, + COUNT(*) FILTER (WHERE outcome = 'lost') AS lost, + ROUND(COUNT(*) FILTER (WHERE outcome = 'won')::numeric + / NULLIF(COUNT(*) FILTER (WHERE outcome IN ('won','lost')),0)*100,1) AS win_rate_pct + FROM win_loss_events + WHERE deal_date >= CURRENT_DATE - INTERVAL '${days} days' + AND customer_segment IS NOT NULL + GROUP BY customer_segment + ORDER BY total DESC + `), + pool.query(` + SELECT COALESCE(wl.form_factor, tx.form_factor) AS form_factor, + COUNT(*) AS total, + COUNT(*) FILTER (WHERE outcome = 'won') AS won, + COUNT(*) FILTER (WHERE outcome = 'lost') AS lost + FROM win_loss_events wl + LEFT JOIN transceivers tx ON tx.id = wl.transceiver_id + WHERE deal_date >= CURRENT_DATE - INTERVAL '${days} days' + GROUP BY COALESCE(wl.form_factor, tx.form_factor) + HAVING COALESCE(wl.form_factor, tx.form_factor) IS NOT NULL + ORDER BY total DESC + `), + // Price delta analysis: where we lost — how far off were we? + pool.query(` + SELECT + ROUND(AVG(competitor_price - our_price)::numeric, 2) AS avg_price_gap, + ROUND(AVG((competitor_price - our_price) / NULLIF(our_price,0) * 100)::numeric, 1) AS avg_gap_pct, + COUNT(*) AS events_with_prices + FROM win_loss_events + WHERE outcome = 'lost' + AND our_price IS NOT NULL AND competitor_price IS NOT NULL + AND deal_date >= CURRENT_DATE - INTERVAL '${days} days' + `), + ]); + + return res.json({ + success: true, + days, + overall: overall.rows[0], + by_segment: bySegment.rows, + by_form_factor: byFormFactor.rows, + price_delta: priceDeltas.rows[0], + }); + } catch (err) { + return res.status(500).json({ success: false, error: String(err) }); + } +}); + +// ── GET /api/win-loss/competitors — Competitor ranking ─────────────────────── +winLossRouter.get("/competitors", async (req: Request, res: Response) => { + const days = Math.min(parseInt(req.query.days as string) || 90, 730); + + try { + const result = await pool.query(` + SELECT + competitor_vendor, + COUNT(*) AS encounters, + COUNT(*) FILTER (WHERE outcome = 'lost') AS losses_to, + COUNT(*) FILTER (WHERE outcome = 'won') AS wins_against, + ROUND(AVG(competitor_price - our_price) + FILTER (WHERE outcome = 'lost' AND our_price IS NOT NULL AND competitor_price IS NOT NULL) + ::numeric, 2) AS avg_price_advantage, -- negative = they beat us on price + ROUND(AVG(competitor_price)::numeric, 2) AS avg_competitor_price + FROM win_loss_events + WHERE deal_date >= CURRENT_DATE - INTERVAL '${days} days' + AND competitor_vendor IS NOT NULL + GROUP BY competitor_vendor + ORDER BY losses_to DESC, encounters DESC + LIMIT 30 + `); + + return res.json({ success: true, competitors: result.rows }); + } catch (err) { + return res.status(500).json({ success: false, error: String(err) }); + } +});