diff --git a/packages/api/src/routes/internal-demand.ts b/packages/api/src/routes/internal-demand.ts index e80f267..136d274 100644 --- a/packages/api/src/routes/internal-demand.ts +++ b/packages/api/src/routes/internal-demand.ts @@ -6,46 +6,25 @@ * 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. IP restriction middleware (localhost / 192.168.x.x only) + * 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 grouped 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/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, NextFunction } from "express"; +import { Router, Request, Response } from "express"; import { pool } from "../db/client"; export const internalDemandRouter = Router(); -// ─── Security: restrict to local / private network only ───────────────────── - -function requireLocalNetwork(req: Request, res: Response, next: NextFunction): void { - const ip = req.ip ?? req.socket.remoteAddress ?? ""; - const allowed = - ip === "127.0.0.1" || - ip === "::1" || - ip === "::ffff:127.0.0.1" || - ip.startsWith("192.168.") || - ip.startsWith("10.") || - ip.startsWith("172.16.") || - ip.startsWith("172.17.") || - ip.startsWith("::ffff:192.168."); - - if (!allowed) { - res.status(403).json({ - success: false, - error: "Internal endpoint — not accessible externally", - }); - return; - } - next(); -} - -internalDemandRouter.use(requireLocalNetwork); - // ─── GET /api/internal/demand/by-speed ────────────────────────────────────── /** * Aggregated demand totals by technology (speed_gbps × form_factor). @@ -237,3 +216,141 @@ internalDemandRouter.get("/forecast-input", async (_req: Request, res: Response) 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" }); + } +}); diff --git a/packages/dashboard/index.html b/packages/dashboard/index.html index 4e66e37..3b27fe1 100644 --- a/packages/dashboard/index.html +++ b/packages/dashboard/index.html @@ -1896,6 +1896,39 @@ + +
+
+ 📦 Demand × Live Stock Analyse — Nachbestellungsbedarf + REAL DATA + Interner Bedarf × Webshop-Lager (kontinuierlich) +
+ +
+ Wird analysiert… +
+ +
+ + + + + + + + + + + + + + + + +
Part NumberFormBedarf/Mo (12M)Bedarf/Mo (3M)DE-LagerDeckung (Tage)StatusTrend
Lade Analyse…
+
+
+
@@ -7137,9 +7170,68 @@ async function loadStock() { } } } catch(demandErr) { - // Internal demand endpoint not available (e.g. external access) - var foxdTable = document.getElementById('foxd-by-speed-body'); - if (foxdTable) foxdTable.innerHTML = '⚠ Demand-Daten nur intern verfügbar'; + console.error('Demand load error:', demandErr); + } + + // ── Stock × Demand Combined Analysis ───────────────────────────────────── + try { + var stockAnalysis = await api('/api/internal/demand/stock-analysis').catch(function() { return null; }); + + if (stockAnalysis && stockAnalysis.success) { + var sa = stockAnalysis.summary; + + // Summary chips + var chips = document.getElementById('stock-analysis-chips'); + if (chips && sa) { + var urgColors = { critical: '#ef4444', low: '#f59e0b', ok: '#22c55e', overstocked: '#06b6d4' }; + chips.innerHTML = + '🚨 Kritisch: ' + sa.reorder_critical + '' + + '⚠ Niedrig: ' + sa.reorder_low + '' + + '✅ OK: ' + sa.reorder_ok + '' + + '📦 Überbestand: ' + sa.overstocked + '' + + '' + sa.total_active_skus + ' aktive SKUs · ' + sa.with_de_stock + ' mit DE-Lager'; + } + + // Table + var sabody = document.getElementById('stock-analysis-body'); + if (sabody && stockAnalysis.data && stockAnalysis.data.length > 0) { + var urgencyLabel = { + critical: '🚨 KRITISCH', + low: '⚠ NIEDRIG', + ok: '✅ OK', + overstocked: '📦 ÜBERBESTAND', + no_demand: '' + }; + sabody.innerHTML = stockAnalysis.data.slice(0, 60).map(function(r) { + var mom = Number(r.momentum_ratio || 1); + var momPct = Math.round((mom - 1) * 100); + var trendColor = mom >= 1.05 ? '#22c55e' : mom >= 0.95 ? '#f59e0b' : '#ef4444'; + var trendStr = (momPct >= 0 ? '+' : '') + momPct + '% ' + (mom >= 1.05 ? '▲' : mom >= 0.95 ? '→' : '▼'); + var deQty = Number(r.webshop_de_qty || 0); + var covDays = r.coverage_days_de != null ? Number(r.coverage_days_de) + 'd' : '—'; + var covColor = r.coverage_days_de == null ? 'var(--text-dim)' + : r.coverage_days_de < 14 ? '#ef4444' + : r.coverage_days_de < 30 ? '#f59e0b' + : '#22c55e'; + return '' + + '' + esc(r.part_number) + '' + + '' + esc(r.form_factor || '—') + '' + + '' + Number(r.demand_12m || 0).toFixed(0) + '' + + '' + Number(r.demand_3m || 0).toFixed(0) + '' + + '' + + (deQty > 0 ? deQty.toLocaleString() : '—') + + '' + + '' + covDays + '' + + '' + (urgencyLabel[r.reorder_urgency] || '—') + '' + + '' + trendStr + '' + + ''; + }).join(''); + } else if (sabody) { + sabody.innerHTML = 'Noch keine Stock-Daten von Flexoptix — Scraper ausführen um Lagermengen zu laden'; + } + } + } catch(saErr) { + console.error('Stock analysis error:', saErr); } } catch(e) { console.error('loadStock error', e);