Rene Fichtmueller 681da54523 feat: Procurement Intelligence Engine (WS0c)
- Migration 019: stock_snapshots, abc_classification, reorder_signals,
  product_lifecycle_events, market_intelligence, crawler_llm_log tables
- Seeded 7 market intel events (OFC 2026, AWS/Azure CapEx, Coherent lead times,
  EU TED tenders, ECOC 2026, IEEE 802.3df)
- Seeded 4 lifecycle events (Cisco SFP-10G-LR EOL, Juniper EOL,
  400ZR ratified, 800G MSA draft)
- Crawler LLM: core.ts (Ollama-based extractor), stock-schema.ts (typed schemas
  + vendor profiles for Flexoptix/FS.com/10Gtek/ATGBICS/ProLabs/Farnell/Mouser),
  validator.ts (rule-based sanity checks + cross-validation)
- market-intelligence.ts scraper: OFC/ECOC, LightReading, IEEE 802.3, EU TED,
  Farnell/Mouser lead times, FierceTelecom — weekly via pg-boss
- computeAbcClassification(): dynamic A/B/C classification from price obs +
  compat count + vendor breadth
- computeReorderSignals(): buy_now/wait/hold/monitor with reasons + signal strength
- API: GET /api/procurement/overview|signals|signals/:id|abc|market-intel|
  stock-trends/:id|lifecycle
- Dashboard: Procurement Intel tab with Reorder Signals, ABC table,
  Market Intel cards, Lifecycle Events
2026-04-01 22:04:33 +02:00

294 lines
14 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)
*/
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" });
}
});