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.
This commit is contained in:
Rene Fichtmueller 2026-04-25 20:41:35 +02:00
parent f162e03978
commit ff4bc34930
2 changed files with 244 additions and 35 deletions

View File

@ -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" });
}
});

View File

@ -1896,6 +1896,39 @@
</div>
</div>
<!-- 📦 Demand vs Live Stock Analysis (real combined) -->
<div class="card" style="overflow:hidden;margin-bottom:1.5rem">
<div style="padding:0.75rem 1rem;border-bottom:1px solid var(--border);font-size:0.85rem;font-weight:600;color:var(--text-bright);display:flex;align-items:center;gap:0.5rem;flex-wrap:wrap">
📦 Demand × Live Stock Analyse — Nachbestellungsbedarf
<span style="font-size:0.65rem;font-weight:700;background:#16a34a22;color:#22c55e;border:1px solid #22c55e66;border-radius:3px;padding:1px 6px">REAL DATA</span>
<span style="margin-left:auto;font-size:0.68rem;color:var(--text-dim);font-weight:400">Interner Bedarf × Webshop-Lager (kontinuierlich)</span>
</div>
<!-- Summary chips -->
<div style="display:flex;gap:0.5rem;flex-wrap:wrap;padding:0.6rem 1rem;border-bottom:1px solid var(--border)" id="stock-analysis-chips">
<span style="color:var(--text-dim);font-size:0.75rem">Wird analysiert…</span>
</div>
<!-- Table -->
<div style="overflow-x:auto;max-height:340px;overflow-y:auto">
<table style="width:100%;border-collapse:collapse;font-size:0.74rem" id="stock-analysis-table">
<thead style="position:sticky;top:0;z-index:1;background:var(--surface2)">
<tr>
<th style="padding:6px 8px;text-align:left;color:var(--text-dim);font-weight:500">Part Number</th>
<th style="padding:6px 8px;text-align:left;color:var(--text-dim);font-weight:500">Form</th>
<th style="padding:6px 8px;text-align:right;color:var(--text-dim);font-weight:500">Bedarf/Mo (12M)</th>
<th style="padding:6px 8px;text-align:right;color:var(--text-dim);font-weight:500">Bedarf/Mo (3M)</th>
<th style="padding:6px 8px;text-align:right;color:var(--text-dim);font-weight:500">DE-Lager</th>
<th style="padding:6px 8px;text-align:right;color:var(--text-dim);font-weight:500">Deckung (Tage)</th>
<th style="padding:6px 8px;text-align:center;color:var(--text-dim);font-weight:500">Status</th>
<th style="padding:6px 8px;text-align:right;color:var(--text-dim);font-weight:500">Trend</th>
</tr>
</thead>
<tbody id="stock-analysis-body">
<tr><td colspan="8" style="text-align:center;padding:2rem;color:var(--text-dim)">Lade Analyse…</td></tr>
</tbody>
</table>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:1.5rem">
<!-- Top Sellers -->
<div class="card" style="overflow:hidden">
@ -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 = '<tr><td colspan="7" style="text-align:center;padding:1rem;color:var(--text-dim);font-size:0.72rem">⚠ Demand-Daten nur intern verfügbar</td></tr>';
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 =
'<span style="background:#ef444422;color:#ef4444;border:1px solid #ef444444;border-radius:20px;padding:3px 12px;font-size:0.72rem;font-weight:700">🚨 Kritisch: ' + sa.reorder_critical + '</span>'
+ '<span style="background:#f59e0b22;color:#f59e0b;border:1px solid #f59e0b44;border-radius:20px;padding:3px 12px;font-size:0.72rem;font-weight:700">⚠ Niedrig: ' + sa.reorder_low + '</span>'
+ '<span style="background:#22c55e22;color:#22c55e;border:1px solid #22c55e44;border-radius:20px;padding:3px 12px;font-size:0.72rem;font-weight:700">✅ OK: ' + sa.reorder_ok + '</span>'
+ '<span style="background:#06b6d422;color:#06b6d4;border:1px solid #06b6d444;border-radius:20px;padding:3px 12px;font-size:0.72rem;font-weight:700">📦 Überbestand: ' + sa.overstocked + '</span>'
+ '<span style="margin-left:auto;color:var(--text-dim);font-size:0.68rem">' + sa.total_active_skus + ' aktive SKUs · ' + sa.with_de_stock + ' mit DE-Lager</span>';
}
// Table
var sabody = document.getElementById('stock-analysis-body');
if (sabody && stockAnalysis.data && stockAnalysis.data.length > 0) {
var urgencyLabel = {
critical: '<span style="background:#ef444422;color:#ef4444;border:1px solid #ef444440;border-radius:10px;padding:1px 8px;font-size:0.65rem;font-weight:700">🚨 KRITISCH</span>',
low: '<span style="background:#f59e0b22;color:#f59e0b;border:1px solid #f59e0b40;border-radius:10px;padding:1px 8px;font-size:0.65rem;font-weight:700">⚠ NIEDRIG</span>',
ok: '<span style="background:#22c55e22;color:#22c55e;border:1px solid #22c55e40;border-radius:10px;padding:1px 8px;font-size:0.65rem;font-weight:700">✅ OK</span>',
overstocked: '<span style="background:#06b6d422;color:#06b6d4;border:1px solid #06b6d440;border-radius:10px;padding:1px 8px;font-size:0.65rem;font-weight:700">📦 ÜBERBESTAND</span>',
no_demand: '<span style="color:var(--text-dim);font-size:0.65rem"></span>'
};
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 '<tr style="border-bottom:1px solid var(--border)">'
+ '<td style="padding:4px 8px;font-family:monospace;font-size:0.72rem;color:var(--text-bright)">' + esc(r.part_number) + '</td>'
+ '<td style="padding:4px 8px;color:var(--text-dim)">' + esc(r.form_factor || '—') + '</td>'
+ '<td style="padding:4px 8px;text-align:right;color:#6366f1;font-weight:600">' + Number(r.demand_12m || 0).toFixed(0) + '</td>'
+ '<td style="padding:4px 8px;text-align:right;color:#06b6d4">' + Number(r.demand_3m || 0).toFixed(0) + '</td>'
+ '<td style="padding:4px 8px;text-align:right;color:' + (deQty > 0 ? '#22c55e' : '#ef4444') + ';font-weight:' + (deQty > 0 ? '600' : '400') + '">'
+ (deQty > 0 ? deQty.toLocaleString() : '—')
+ '</td>'
+ '<td style="padding:4px 8px;text-align:right;color:' + covColor + ';font-weight:600">' + covDays + '</td>'
+ '<td style="padding:4px 8px;text-align:center">' + (urgencyLabel[r.reorder_urgency] || '—') + '</td>'
+ '<td style="padding:4px 8px;text-align:right;color:' + trendColor + ';font-size:0.7rem">' + trendStr + '</td>'
+ '</tr>';
}).join('');
} else if (sabody) {
sabody.innerHTML = '<tr><td colspan="8" style="text-align:center;padding:2rem;color:var(--text-dim);font-size:0.72rem">Noch keine Stock-Daten von Flexoptix — Scraper ausführen um Lagermengen zu laden</td></tr>';
}
}
} catch(saErr) {
console.error('Stock analysis error:', saErr);
}
} catch(e) {
console.error('loadStock error', e);