Two new procurement sub-tabs backed by live database tables: 📦 Internal Demand (flexoptix_internal_demand, 8,585 SKUs): - Velocity cards: fast_mover (70 SKUs, 53k units/12M), regular, slow, dead stock - Filterable table with demand_12m, demand_3m, trend %, form factor - GET /api/procurement/internal-demand — summary + paginated rows 🤖 AI Clusters (ai_cluster_announcements, 396 rows last 30d): - Live datacenter build announcements with estimated transceiver demand - Stats: total announcements, MW sum, distinct companies, total ~transceivers - Filter for entries with transceiver estimates; time range selector - GET /api/procurement/ai-clusters — data + period stats Also: replaced misleading DEMO DATA banners on Reorder Signals and ABC Classification sections with informational notes pointing to real data.
427 lines
19 KiB
TypeScript
427 lines
19 KiB
TypeScript
/**
|
|
* 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)
|
|
* 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";
|
|
|
|
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" });
|
|
}
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// 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<string, string> = {
|
|
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" });
|
|
}
|
|
});
|