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:
parent
f162e03978
commit
ff4bc34930
@ -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/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" });
|
||||
}
|
||||
});
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user