diff --git a/packages/api/src/routes/procurement.ts b/packages/api/src/routes/procurement.ts index bc02418..80f4dfe 100644 --- a/packages/api/src/routes/procurement.ts +++ b/packages/api/src/routes/procurement.ts @@ -819,6 +819,24 @@ procurementRouter.get("/availability", async (_req: Request, res: Response) => { ) s ON true WHERE t.speed_gbps IN (100,200,400,800,1600) GROUP BY t.speed_gbps + ), + pm AS ( + -- per-SKU paired price momentum (30d vs prior 30d), bias-free: only SKUs in + -- both windows; aggregate the median per-SKU pct delta per speed tier. + SELECT speed_gbps, + ROUND(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY (med_now - med_prior) / NULLIF(med_prior,0) * 100)::numeric, 1) AS price_delta_pct + FROM ( + SELECT t.speed_gbps, t.id, + PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY po.price) FILTER (WHERE po.time >= NOW() - INTERVAL '30 days') AS med_now, + PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY po.price) FILTER (WHERE po.time >= NOW() - INTERVAL '60 days' AND po.time < NOW() - INTERVAL '30 days') AS med_prior, + COUNT(*) FILTER (WHERE po.time >= NOW() - INTERVAL '30 days') AS n_now, + COUNT(*) FILTER (WHERE po.time >= NOW() - INTERVAL '60 days' AND po.time < NOW() - INTERVAL '30 days') AS n_prior + FROM price_observations po JOIN transceivers t ON t.id = po.transceiver_id + WHERE t.speed_gbps IN (100,200,400,800,1600) AND po.price > 0 AND COALESCE(po.is_anomalous,false) = false + GROUP BY t.speed_gbps, t.id + ) per + WHERE med_now IS NOT NULL AND med_prior IS NOT NULL AND med_prior > 0 AND n_now >= 2 AND n_prior >= 2 + GROUP BY speed_gbps ) SELECT sk.speed_gbps, @@ -828,14 +846,16 @@ procurementRouter.get("/availability", async (_req: Request, res: Response) => { COALESCE(st.skus_in_stock,0)::int AS skus_in_stock, COALESCE(st.skus_with_stock,0)::int AS skus_with_stock, CASE WHEN COALESCE(st.skus_with_stock,0) > 0 - THEN ROUND(100.0 * st.skus_in_stock / st.skus_with_stock)::int ELSE NULL END AS in_stock_pct + THEN ROUND(100.0 * st.skus_in_stock / st.skus_with_stock)::int ELSE NULL END AS in_stock_pct, + pm.price_delta_pct FROM sk LEFT JOIN sup ON sup.speed_gbps = sk.speed_gbps LEFT JOIN st ON st.speed_gbps = sk.speed_gbps + LEFT JOIN pm ON pm.speed_gbps = sk.speed_gbps ORDER BY sk.speed_gbps DESC `); - type Row = { speed_gbps: string; sku_count: number; suppliers: number; suppliers_prior: number; skus_in_stock: number; skus_with_stock: number; in_stock_pct: number | null }; + type Row = { speed_gbps: string; sku_count: number; suppliers: number; suppliers_prior: number; skus_in_stock: number; skus_with_stock: number; in_stock_pct: number | null; price_delta_pct: string | null }; const tiers = (result.rows as Row[]).map((r) => { const sup = r.suppliers ?? 0; const inStockPct = r.in_stock_pct ?? null; @@ -849,17 +869,33 @@ procurementRouter.get("/availability", async (_req: Request, res: Response) => { const trend = r.suppliers_prior > 0 ? (sup < r.suppliers_prior ? "tightening" : sup > r.suppliers_prior ? "loosening" : "stable") : "stable"; + const priceDelta = r.price_delta_pct != null ? parseFloat(r.price_delta_pct) : null; + const priceTrend = priceDelta === null ? "unknown" : priceDelta > 3 ? "rising" : priceDelta < -3 ? "falling" : "stable"; const drivers: string[] = [`${sup} active suppliers`]; + if (trend === "tightening") drivers.push(`supplier base shrank ${r.suppliers_prior}→${sup} vs prior 45d`); + else if (trend === "loosening") drivers.push(`supplier base grew ${r.suppliers_prior}→${sup} vs prior 45d`); if (inStockPct !== null) drivers.push(`${inStockPct}% of tracked SKUs in stock`); - drivers.push(`${r.sku_count} SKUs`); - if (trend === "tightening") drivers.push(`supplier base shrinking (${r.suppliers_prior}→${sup})`); + if (priceDelta !== null) drivers.push(`price ${priceDelta >= 0 ? "+" : ""}${priceDelta}% (30d, same-SKU median)`); + drivers.push(`${r.sku_count} SKUs catalogued`); + // Plain-language WHY, built only from real per-speed signals + const reasons: string[] = []; + if (availability === "scarce") reasons.push(sup <= 3 ? `only ${sup} supplier(s) offer it` : `near-zero stock coverage`); + else if (availability === "constrained") reasons.push(`limited supplier base (${sup})`); + if (trend === "tightening") reasons.push(`supplier base is shrinking`); + if (priceTrend === "rising") reasons.push(`prices rising ${priceDelta}% on the same parts`); + if (priceTrend === "falling") reasons.push(`prices easing ${priceDelta}%`); + if (inStockPct !== null && inStockPct < 50) reasons.push(`${inStockPct}% in-stock coverage`); + const why = reasons.length ? reasons.join("; ") : `broad supply: ${sup} suppliers, ${inStockPct ?? "n/a"}% in stock, stable pricing`; return { speed_gbps: parseFloat(r.speed_gbps), sku_count: r.sku_count, suppliers: sup, in_stock_pct: inStockPct, + price_delta_pct: priceDelta, + price_trend: priceTrend, availability, trend, + why, drivers, }; });