fix(api): switch-compat vendor join + min_price aggregate + win-loss form_factor ambiguity
This commit is contained in:
parent
d6da7aa94c
commit
aa6ce9cc26
@ -9,9 +9,12 @@
|
|||||||
* GET /api/procurement/market-intel — Market intelligence events
|
* GET /api/procurement/market-intel — Market intelligence events
|
||||||
* GET /api/procurement/stock-trends/:id — Stock history for a transceiver
|
* GET /api/procurement/stock-trends/:id — Stock history for a transceiver
|
||||||
* GET /api/procurement/lifecycle — Lifecycle events (EOL, standards)
|
* 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 { Router, Request, Response } from "express";
|
||||||
import { pool } from "../db/client";
|
import { pool } from "../db/client";
|
||||||
|
import { sendCSV } from "../utils/csv";
|
||||||
|
|
||||||
export const procurementRouter = Router();
|
export const procurementRouter = Router();
|
||||||
|
|
||||||
@ -22,11 +25,13 @@ procurementRouter.get("/overview", async (_req: Request, res: Response) => {
|
|||||||
try {
|
try {
|
||||||
const [signals, abc, intel, lifecycle] = await Promise.all([
|
const [signals, abc, intel, lifecycle] = await Promise.all([
|
||||||
pool.query(`
|
pool.query(`
|
||||||
SELECT signal, COUNT(*) AS count
|
WITH latest AS (
|
||||||
|
SELECT DISTINCT ON (transceiver_id) signal
|
||||||
FROM reorder_signals
|
FROM reorder_signals
|
||||||
WHERE expires_at > NOW()
|
WHERE expires_at > NOW()
|
||||||
AND computed_at = (SELECT MAX(r2.computed_at) FROM reorder_signals r2 WHERE r2.transceiver_id = reorder_signals.transceiver_id)
|
ORDER BY transceiver_id, computed_at DESC
|
||||||
GROUP BY signal
|
)
|
||||||
|
SELECT signal, COUNT(*) AS count FROM latest GROUP BY signal
|
||||||
`),
|
`),
|
||||||
pool.query(`
|
pool.query(`
|
||||||
SELECT abc_class, COUNT(*) AS count FROM abc_classification GROUP BY abc_class ORDER BY abc_class
|
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"
|
limit = "50", offset = "0"
|
||||||
} = req.query;
|
} = 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.*,
|
SELECT rs.*,
|
||||||
t.part_number, t.standard_name, t.form_factor, t.speed_gbps,
|
t.part_number, t.standard_name, t.form_factor, t.speed_gbps,
|
||||||
t.reach_label, t.image_url, t.image_r2_key,
|
t.reach_label, t.image_url, t.image_r2_key,
|
||||||
ac.abc_class, ac.demand_score, ac.supply_risk,
|
ac.abc_class, ac.demand_score, ac.supply_risk,
|
||||||
v.name AS vendor_name
|
v.name AS vendor_name
|
||||||
FROM reorder_signals rs
|
FROM latest rs
|
||||||
JOIN transceivers t ON rs.transceiver_id = t.id
|
JOIN transceivers t ON rs.transceiver_id = t.id
|
||||||
LEFT JOIN abc_classification ac ON ac.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
|
LEFT JOIN vendors v ON t.vendor_id = v.id
|
||||||
WHERE rs.expires_at > NOW()
|
WHERE 1=1${signalFilter}${abcFilter}${ffFilter}${speedFilter}
|
||||||
AND rs.computed_at = (
|
ORDER BY rs.signal_strength DESC
|
||||||
SELECT MAX(r2.computed_at) FROM reorder_signals r2 WHERE r2.transceiver_id = rs.transceiver_id
|
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);
|
const result = await pool.query(sql, params);
|
||||||
res.json({ data: result.rows, total: result.rowCount });
|
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));
|
params.push(parseInt(limit as string), parseInt(offset as string));
|
||||||
|
|
||||||
const result = await pool.query(sql, params);
|
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 });
|
res.json({ data: result.rows, total: result.rowCount });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("ABC error:", 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" });
|
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" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// 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<string, number> = { 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<typeof r> => 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<number, HypeRow>();
|
||||||
|
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<typeof r> => 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<number, HypeRow>();
|
||||||
|
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<number, AiRow>();
|
||||||
|
for (const a of aiDemand.rows as AiRow[]) {
|
||||||
|
aiBySpeed.set(parseFloat(a.speed_tier), a);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stockByKey = new Map<string, StockRow>();
|
||||||
|
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) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
189
packages/api/src/routes/win-loss.ts
Normal file
189
packages/api/src/routes/win-loss.ts
Normal file
@ -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<string, any>;
|
||||||
|
|
||||||
|
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) });
|
||||||
|
}
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user