/** * Internal Demand API — Flexoptix Sales Velocity * ───────────────────────────────────────────────────────────────────────────── * ⚠️ SECURITY: This router ONLY serves aggregated, anonymized demand data. * Raw SKU-level records (individual demand figures) are NEVER returned. * The underlying table is protected by: * 1. PostgreSQL Row Level Security (RLS) — is_internal = TRUE guard * 2. This router never returns individual rows from flexoptix_internal_demand * 3. JWT auth middleware (requireAuth) in index.ts — all /api/* routes * * Note: IP-restriction removed — Cloudflare tunnel rewrites X-Forwarded-For * to the real user IP, so `trust proxy: 1` would block legitimate dashboard * users. JWT is the real security boundary; RLS + aggregation handle the rest. * * Routes: * GET /api/internal/demand/by-speed — Aggregated demand by technology * GET /api/internal/demand/velocity — Velocity class breakdown (counts only) * GET /api/internal/demand/hype-weights — Demand-calibrated hype cycle weights * GET /api/internal/demand/forecast-input — Forecast calibration for Norton-Bass * GET /api/internal/demand/stock-analysis — Demand vs live webshop stock (combined) */ import { Router, Request, Response } from "express"; import { pool } from "../db/client"; export const internalDemandRouter = Router(); // ─── GET /api/internal/demand/by-speed ────────────────────────────────────── /** * Aggregated demand totals by technology (speed_gbps × form_factor). * Safe to expose — no individual SKUs, only category sums. */ internalDemandRouter.get("/by-speed", async (_req: Request, res: Response) => { try { const { rows } = await pool.query(` SELECT t.speed_gbps, t.form_factor, COUNT(DISTINCT d.sku) AS sku_count, ROUND(SUM(d.demand_12m)) AS total_demand_12m, ROUND(SUM(d.demand_3m)) AS total_demand_3m, ROUND(AVG(d.demand_12m), 1) AS avg_demand_12m, ROUND(AVG(d.demand_3m), 1) AS avg_demand_3m, ROUND(AVG(COALESCE(d.demand_trend_pct, 0)), 1) AS avg_trend_pct, COUNT(*) FILTER (WHERE d.velocity_class = 'fast_mover') AS fast_movers, COUNT(*) FILTER (WHERE d.velocity_class = 'regular') AS regular, COUNT(*) FILTER (WHERE d.velocity_class = 'slow_mover') AS slow_movers, -- Momentum: 3m vs 12m ratio (>1 = accelerating) ROUND( CASE WHEN SUM(d.demand_12m) > 0 THEN SUM(d.demand_3m) / SUM(d.demand_12m) ELSE 1 END, 3 ) AS momentum_ratio FROM flexoptix_internal_demand d JOIN transceivers t ON t.id = d.transceiver_id WHERE d.demand_12m > 0 GROUP BY t.speed_gbps, t.form_factor ORDER BY total_demand_12m DESC `); res.json({ success: true, data: rows }); } catch (err) { console.error("GET /api/internal/demand/by-speed error:", err); res.status(500).json({ success: false, error: "Internal server error" }); } }); // ─── GET /api/internal/demand/velocity ────────────────────────────────────── /** * Velocity class distribution across all 8,585 SKUs. */ internalDemandRouter.get("/velocity", async (_req: Request, res: Response) => { try { const { rows } = await pool.query(` SELECT velocity_class, COUNT(*) AS sku_count, ROUND(SUM(demand_12m)) AS total_demand_12m, ROUND(AVG(demand_12m), 1) AS avg_demand_12m, MAX(demand_12m) AS peak_demand_12m FROM flexoptix_internal_demand GROUP BY velocity_class ORDER BY total_demand_12m DESC `); const total = rows.reduce((s, r) => s + parseInt(r.sku_count, 10), 0); res.json({ success: true, data: { total_skus: total, classes: rows.map(r => ({ ...r, share_pct: Math.round((parseInt(r.sku_count, 10) / total) * 1000) / 10, })), }, }); } catch (err) { console.error("GET /api/internal/demand/velocity error:", err); res.status(500).json({ success: false, error: "Internal server error" }); } }); // ─── GET /api/internal/demand/hype-weights ────────────────────────────────── /** * Returns demand-calibrated weights per technology for hype cycle recalibration. * Maps real Flexoptix sales velocity to Norton-Bass market potential (m) adjustments. * * Logic: * - Normalize total_demand_12m across all technologies → relative weight 0–1 * - Momentum ratio > 1 = accelerating (positive hype signal) * - momentum ratio < 1 = decelerating (trough/plateau signal) */ internalDemandRouter.get("/hype-weights", async (_req: Request, res: Response) => { try { const { rows } = await pool.query(` WITH base AS ( SELECT t.speed_gbps, t.form_factor, SUM(d.demand_12m) AS demand_12m, SUM(d.demand_3m) AS demand_3m, COUNT(d.sku) AS sku_count FROM flexoptix_internal_demand d JOIN transceivers t ON t.id = d.transceiver_id WHERE d.demand_12m > 0 GROUP BY t.speed_gbps, t.form_factor ), totals AS ( SELECT SUM(demand_12m) AS grand_total FROM base ) SELECT b.speed_gbps, b.form_factor, b.sku_count, ROUND(b.demand_12m) AS demand_12m, ROUND(b.demand_3m) AS demand_3m, -- Normalized weight (0–1), sum = 1 across all techs ROUND(b.demand_12m / t.grand_total, 4) AS demand_weight, -- Momentum ratio: 3m vs 12m (>1 = growing, <1 = declining) ROUND( CASE WHEN b.demand_12m > 0 THEN b.demand_3m / b.demand_12m ELSE 1 END, 3 ) AS momentum_ratio, -- Suggested hype signal based on momentum CASE WHEN b.demand_3m / NULLIF(b.demand_12m, 0) > 1.15 THEN 'climbing' WHEN b.demand_3m / NULLIF(b.demand_12m, 0) > 0.90 THEN 'stable' WHEN b.demand_3m / NULLIF(b.demand_12m, 0) > 0.70 THEN 'cooling' ELSE 'declining' END AS demand_signal FROM base b, totals t ORDER BY demand_weight DESC `); res.json({ success: true, data: rows }); } catch (err) { console.error("GET /api/internal/demand/hype-weights error:", err); res.status(500).json({ success: false, error: "Internal server error" }); } }); // ─── GET /api/internal/demand/forecast-input ──────────────────────────────── /** * Structured forecast calibration input for the Norton-Bass engine. * Provides real-demand scaling factors for the volume forecast. * * Use in forecast.ts to replace the `totalMarketPorts * marketShare` estimate * with real observed Flexoptix throughput per technology. */ internalDemandRouter.get("/forecast-input", async (_req: Request, res: Response) => { try { const { rows } = await pool.query(` SELECT t.speed_gbps, t.form_factor, ROUND(SUM(d.demand_12m)) AS units_per_month_12m, ROUND(SUM(d.demand_3m)) AS units_per_month_3m, ROUND(SUM(d.demand_12m) * 12) AS units_annual_run_rate, ROUND( CASE WHEN SUM(d.demand_12m) > 0 THEN SUM(d.demand_3m) / SUM(d.demand_12m) ELSE 1 END, 3 ) AS growth_factor, -- Projected units for 3m forecast using 3m run-rate ROUND(SUM(d.demand_3m) * 3) AS projected_units_3m, -- Projected units for 12m forecast using 12m run-rate with momentum ROUND( SUM(d.demand_12m) * 12 * GREATEST(0.5, LEAST(2.0, CASE WHEN SUM(d.demand_12m) > 0 THEN SUM(d.demand_3m) / SUM(d.demand_12m) ELSE 1 END )) ) AS projected_units_12m FROM flexoptix_internal_demand d JOIN transceivers t ON t.id = d.transceiver_id WHERE d.demand_12m > 0 OR d.demand_3m > 0 GROUP BY t.speed_gbps, t.form_factor ORDER BY units_per_month_12m DESC `); res.json({ success: true, data: rows, meta: { note: "Aggregated Flexoptix throughput — safe for dashboard use. No raw SKU data exposed.", source: "flexoptix_internal_demand", last_updated: new Date().toISOString(), }, }); } catch (err) { console.error("GET /api/internal/demand/forecast-input error:", err); res.status(500).json({ success: false, error: "Internal server error" }); } }); // ─── GET /api/internal/demand/stock-analysis ──────────────────────────────── /** * Combined analysis: internal Flexoptix demand vs live webshop stock observations. * * Joins flexoptix_internal_demand (real sales velocity) with the latest * stock_observations scraped from flexoptix.net and other sources. * * Returns per-transceiver: * - demand_12m / demand_3m: real monthly demand from internal XLSX * - webshop_de_qty: current DE-Lager from live scrape * - webshop_global_qty: current Global-Lager * - coverage_days_de: how many days current DE stock covers demand * - reorder_urgency: 'critical' | 'low' | 'ok' | 'overstocked' * - momentum_ratio: 3m vs 12m demand trend * * This is the foundation for continuous procurement analysis. * Safe to expose — no individual SKU demand figures, data aggregated per transceiver. */ internalDemandRouter.get("/stock-analysis", async (_req: Request, res: Response) => { try { const { rows } = await pool.query(` WITH latest_stock AS ( -- Most recent stock observation per transceiver from Flexoptix vendor SELECT DISTINCT ON (so.transceiver_id) so.transceiver_id, so.warehouse_de_qty, so.warehouse_global_qty, so.backorder_qty, so.in_stock, so.price_net, so.price_currency, so.units_sold AS scraper_units_sold, so.time AS last_scraped FROM stock_observations so JOIN vendors v ON v.id = so.source_vendor_id WHERE v.slug ILIKE '%flexoptix%' OR v.website ILIKE '%flexoptix%' ORDER BY so.transceiver_id, so.time DESC ), all_vendors_stock AS ( -- Also grab best available stock from ANY vendor if no Flexoptix data SELECT DISTINCT ON (so.transceiver_id) so.transceiver_id, so.warehouse_de_qty AS any_de_qty, so.warehouse_global_qty AS any_global_qty, so.in_stock AS any_in_stock, so.price_net AS any_price, so.time AS any_scraped FROM stock_observations so ORDER BY so.transceiver_id, so.time DESC ) SELECT t.part_number, t.form_factor, t.speed_gbps, t.fiber_type, -- Internal demand (real Flexoptix sales velocity) d.demand_12m, d.demand_3m, d.velocity_class, ROUND(d.demand_trend_pct, 1) AS demand_trend_pct, -- Live webshop stock (Flexoptix-specific if available, else any vendor) COALESCE(ls.warehouse_de_qty, avs.any_de_qty, 0) AS webshop_de_qty, COALESCE(ls.warehouse_global_qty, avs.any_global_qty, 0) AS webshop_global_qty, COALESCE(ls.in_stock, avs.any_in_stock, false) AS webshop_in_stock, COALESCE(ls.price_net, avs.any_price) AS webshop_price, COALESCE(ls.last_scraped, avs.any_scraped) AS last_scraped, ls.scraper_units_sold, -- Coverage analysis: how many days does current DE stock cover demand? CASE WHEN d.demand_12m > 0 AND COALESCE(ls.warehouse_de_qty, avs.any_de_qty, 0) > 0 THEN ROUND( COALESCE(ls.warehouse_de_qty, avs.any_de_qty, 0)::NUMERIC / (d.demand_12m / 30.0) ) ELSE NULL END AS coverage_days_de, -- Reorder urgency signal CASE WHEN d.demand_12m = 0 THEN 'no_demand' WHEN COALESCE(ls.warehouse_de_qty, avs.any_de_qty, 0) = 0 AND d.demand_12m >= 10 THEN 'critical' WHEN COALESCE(ls.warehouse_de_qty, avs.any_de_qty, 0)::NUMERIC / NULLIF(d.demand_12m / 30.0, 0) < 14 AND d.demand_12m >= 10 THEN 'low' WHEN COALESCE(ls.warehouse_de_qty, avs.any_de_qty, 0)::NUMERIC / NULLIF(d.demand_12m / 30.0, 0) > 90 AND d.demand_12m > 0 THEN 'overstocked' ELSE 'ok' END AS reorder_urgency, -- Momentum ROUND( CASE WHEN d.demand_12m > 0 THEN d.demand_3m / d.demand_12m ELSE 1 END, 3 ) AS momentum_ratio FROM flexoptix_internal_demand d JOIN transceivers t ON t.id = d.transceiver_id LEFT JOIN latest_stock ls ON ls.transceiver_id = d.transceiver_id LEFT JOIN all_vendors_stock avs ON avs.transceiver_id = d.transceiver_id WHERE d.demand_12m > 0 OR d.demand_3m > 0 ORDER BY d.demand_12m DESC `); // Summary stats const critical = rows.filter(r => r.reorder_urgency === "critical").length; const low = rows.filter(r => r.reorder_urgency === "low").length; const ok = rows.filter(r => r.reorder_urgency === "ok").length; const overstock = rows.filter(r => r.reorder_urgency === "overstocked").length; const withStock = rows.filter(r => parseInt(r.webshop_de_qty ?? "0", 10) > 0).length; res.json({ success: true, data: rows, summary: { total_active_skus: rows.length, with_de_stock: withStock, reorder_critical: critical, reorder_low: low, reorder_ok: ok, overstocked: overstock, }, meta: { note: "Demand (internal) × Live stock (scraped). Aggregated per transceiver, no raw demand SKUs.", last_updated: new Date().toISOString(), }, }); } catch (err) { console.error("GET /api/internal/demand/stock-analysis error:", err); res.status(500).json({ success: false, error: "Internal server error" }); } });