transceiver-db/packages/api/src/routes/internal-demand.ts
Rene Fichtmueller ff4bc34930 fix: remove IP restriction from internal-demand (Cloudflare tunnel breaks req.ip)
JWT auth + RLS is the real security boundary. IP check was blocking legitimate
dashboard users accessing via Cloudflare tunnel (X-Forwarded-For = real user IP,
not 127.0.0.1). Added /api/internal/demand/stock-analysis: demand × live stock
combined analysis with reorder_urgency, coverage_days_de, momentum_ratio.
Dashboard: new Demand × Live Stock Analyse panel with critical/low/ok/overstock chips.
2026-04-25 20:41:35 +02:00

357 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 01
* - 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 (01), 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" });
}
});